File: bf.py 1 #!/usr/bin/python 2 3 # The MIT License (MIT) 4 # 5 # Copyright (c) 2026 pacman64 6 # 7 # Permission is hereby granted, free of charge, to any person obtaining a copy 8 # of this software and associated documentation files (the "Software"), to deal 9 # in the Software without restriction, including without limitation the rights 10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 # copies of the Software, and to permit persons to whom the Software is 12 # furnished to do so, subject to the following conditions: 13 # 14 # The above copyright notice and this permission notice shall be included in 15 # all copies or substantial portions of the Software. 16 # 17 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 # SOFTWARE. 24 25 26 from curses import ( 27 cbreak, curs_set, endwin, initscr, noecho, resetty, savetty, set_escdelay, 28 A_NORMAL, A_REVERSE, A_UNDERLINE, 29 A_DIM, A_ITALIC 30 ) 31 from itertools import islice 32 from os import chdir, dup2, getcwd, readlink, scandir 33 from os.path import join, isdir 34 from sys import argv, stderr, stdin 35 36 37 info = ''' 38 bf [options...] [file/folder...] 39 40 41 Browse Folders is a text user-interface (TUI) to do just that. By default 42 it starts browsing from the current folder, but you can choose a different 43 starting point as an optional cmd-line argument when starting this script. 44 45 It's also a (UTF-8) plain-text file viewer; when the optional command-line 46 argument is a filename (instead of a starting folder) it acts purely as a 47 viewer for the file given. 48 49 50 Enter Quit this app, emitting the currently-selected entry 51 Escape Quit this app without emitting an entry; quit text viewers 52 F1 Toggle help-message screen; the Escape key also quits it 53 F5 Update current-folder entries, in case they've changed 54 F10 Quit this app without emitting an entry; quit text viewers 55 F12 Quit this app without emitting an entry; quit text viewers 56 57 Left Go to the current folder's parent folder 58 Right Go to the currently-selected folder 59 Backspace Go to the current folder's parent folder 60 Tab Go to the currently-selected folder 61 62 Home Select the first entry in the current folder 63 End Select the last entry in the current folder 64 Up Select the entry before the currently selected one 65 Down Select the entry after the currently selected one 66 Page Up Select entry by jumping one screen backward 67 Page Down Select entry by jumping one screen forward 68 69 [Other] Jump to the first/next entry whose name starts with that 70 letter or digit; letters are matched case-insensitively 71 72 73 Escape quits the app without emitting the currently-selected item and with 74 an error-code, while Enter emits the selected item, quitting successfully. 75 76 Folders are shown without a file-size, and are always shown before files. 77 78 Some file/folder entries may be special and/or give an error when queried 79 for their file-size: these are shown with a question mark where their size 80 would normally be. 81 82 The right side of the screen also shows little up/down arrow symbols when 83 there are more entries before/after the ones currently showing. 84 85 The only options are one of the help options, which show this message: 86 these are `-h`, `--h`, `-help`, and `--help`, without the quotes. 87 88 You can also view this help message by pressing the F1 key while browsing 89 folders: the help viewer works the same as the text-file viewer; you can 90 quit the help screen with the Escape key, or with the F1 key. 91 92 When things have changed in the current folder, you can press the F5 key 93 to reload the entries on screen, so there's no need to manually get out 94 and back into the current folder as a workaround. 95 ''' 96 97 98 class SimpleTUI: 99 ''' 100 Manager to start/stop a no-color text user-interface (TUI), allowing for 101 standard input/output to be used normally before method `start` is called 102 and after method `stop` is called. After calling is method `start`, its 103 field `screen` has the ncurses value for all the interactive input-output. 104 ''' 105 106 def __init__(self): 107 self.screen = None 108 109 def start(self, out_fd = -1, esc_delay = -1): 110 ''' 111 Start interactive-mode: the first optional argument should be more 112 than 2, if given, since it would mess with stdio, which is precisely 113 what it's meant to avoid doing. 114 ''' 115 116 if out_fd >= 0: 117 # keep original stdout as /dev/fd/... 118 dup2(1, out_fd) 119 # separate live output from final (optional) result on stdout 120 with open('/dev/tty', 'rb') as inp, open('/dev/tty', 'wb') as out: 121 dup2(inp.fileno(), 0) 122 dup2(out.fileno(), 1) 123 124 self.screen = initscr() 125 savetty() 126 noecho() 127 cbreak() 128 self.screen.keypad(True) 129 curs_set(0) 130 if esc_delay >= 0: 131 set_escdelay(esc_delay) 132 133 def stop(self): 134 'Stop interactive-mode.' 135 if self.screen: 136 resetty() 137 endwin() 138 139 140 class FolderBrowserTUI: 141 ''' 142 This is a scrollable viewer to browse folders. After initializing it with 143 a TUI screen value, you can configure various fields before calling its 144 method `run`: 145 - max_view_size, which limits of big (in bytes) text files can be 146 viewed/loaded; negative values disables text-viewer functionality 147 - side_step, which controls the speed of lateral side-scrolling 148 - handlers, which has all ncurses key-bindings for the viewer 149 ''' 150 151 def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')): 152 'Optional argument controls which ncurses keys quit the viewer.' 153 154 self.max_view_size = -1 155 self.side_step = 1 156 self.handlers = { 157 'KEY_RESIZE': lambda: self._on_resize(), 158 'KEY_UP': lambda: self._on_up(), 159 'KEY_DOWN': lambda: self._on_down(), 160 'KEY_NPAGE': lambda: self._on_page_down(), 161 'KEY_PPAGE': lambda: self._on_page_up(), 162 'KEY_HOME': lambda: self._on_home(), 163 'KEY_END': lambda: self._on_end(), 164 'KEY_LEFT': lambda: self._on_left(), 165 'KEY_RIGHT': lambda: self._on_right(), 166 'KEY_F(5)': lambda: self._on_refresh(), 167 } 168 if quit_set: 169 for k in quit_set: 170 self.handlers[k] = None 171 172 self._screen = screen 173 self._inner_width = 0 174 self._inner_height = 0 175 self._max_line_width = 0 176 self._pick = 0 177 self._max_top = 0 178 self._max_left = 0 179 self._current_folder = '' 180 self._entries = tuple() 181 self._trail = [] 182 self.pick = None 183 184 def run(self, folder): 185 'Interactively view/browse folders, starting from the path given.' 186 187 self._change(folder) 188 self._on_resize() 189 190 while True: 191 self._redraw() 192 k = self._screen.getkey() 193 if k == '\n': 194 e = self._entries 195 pick = e[self._pick][0] if len(e) else None 196 self._entries = tuple() 197 return (pick, k) 198 199 if k in self.handlers: 200 h = self.handlers[k] 201 if h is None: 202 self._entries = tuple() 203 return ('', None) 204 if h() is False: 205 pick = self._entries[self._pick][0] 206 self._entries = tuple() 207 return (pick, None) 208 elif len(k) == 1: 209 i = self._seek(k, self._pick + 1) 210 if i < 0: 211 i = self._seek(k, 0) 212 if i >= 0: 213 self._pick = i 214 215 def _change(self, folder): 216 if folder == '..': 217 chdir(folder) 218 self._current_folder = getcwd() 219 self._scan() 220 if len(self._trail) > 0: 221 self._pick_name(self._trail[:len(self._trail) - 1], 0) 222 self._trail.pop() 223 return 224 225 if len(self._entries) > 0: 226 self._trail.extend(self._entries[self._pick][0]) 227 chdir(folder) 228 self._current_folder = getcwd() 229 self._scan() 230 self._pick = 0 231 232 def _fit_string(self, s): 233 maxlen = max(self._inner_width, 0) 234 return s if len(s) <= maxlen else s[:maxlen] 235 236 def _pick_name(self, name, fallback = 0): 237 self._pick = fallback 238 for i, e in enumerate(self._entries): 239 if e[0] == name: 240 self._pick = i 241 return 242 243 def _redraw(self): 244 title = self._fit_string(self._current_folder) 245 entries = self._entries 246 screen = self._screen 247 iw = self._inner_width 248 ih = self._inner_height 249 250 if iw < 10 or ih < 10: 251 return 252 253 screen.erase() 254 255 if title: 256 screen.addstr(0, 0, f'{self._current_folder:<{iw}}') 257 258 start = self._pick - (self._pick % ih) 259 stop = start + ih 260 261 at_bottom = start >= len(entries) - ih 262 if at_bottom or len(entries) == 0: 263 msg = f'END ({len(entries):,})' 264 else: 265 msg = f'({start + 1:,} / {len(entries):,})' 266 screen.addstr(0, iw - len(msg), self._fit_string(msg)) 267 268 for i, e in enumerate(islice(entries, start, stop)): 269 try: 270 if not e[2]: 271 screen.addnstr(i + 1, 0, f'{e[1]:15,}', iw, A_NORMAL) 272 style = A_REVERSE if i == self._pick % ih else A_NORMAL 273 if e[3]: 274 s = f'{e[0]} -> {e[4]}' 275 style = style | A_UNDERLINE | A_ITALIC 276 else: 277 s = e[0] 278 indent = 17 279 screen.addnstr(i + 1, indent, s, iw - indent, style) 280 except Exception as e: 281 # some utf-8 files have lines which upset func addstr 282 screen.addnstr(i + 1, 0, '?' * len(e[0]), iw, style) 283 284 # show up/down arrows 285 if start > 0: 286 self._screen.addstr(1, iw - 1, '▲') 287 if at_bottom and len(entries) > 0: 288 self._screen.addstr(ih, iw - 1, '▼') 289 290 screen.refresh() 291 292 def _scan(self): 293 def safe_size(e): 294 try: 295 return e.stat().st_size 296 except Exception: 297 return -1 298 299 def f(e): 300 folder = e.is_dir() 301 link = e.is_symlink() 302 size = 0 if folder else safe_size(e) 303 path = e.path.removeprefix('./') 304 target = readlink(path) if link else '' 305 return (path, size, folder, link, target) 306 307 def key(e): 308 name, _, folder, _, _ = e 309 return (not folder, name) 310 311 self._entries = sorted((f(e) for e in scandir()), key=key) 312 if len(self._entries) > 0: 313 self._max_line_width = max(len(e[0]) for e in self._entries) 314 else: 315 self._max_line_width = 0 316 317 def _seek(self, k, start): 318 if len(k) != 1: 319 return -1 320 321 k = k.lower() 322 for i, e in enumerate(islice(self._entries, start, None)): 323 name = e[0] 324 if name.startswith(k) or name.lower().startswith(k): 325 return start + i 326 return -1 327 328 def _on_resize(self): 329 height, width = self._screen.getmaxyx() 330 self._inner_width = width - 1 331 self._inner_height = height - 1 332 self._max_top = max(len(self._entries) - self._inner_height, 0) 333 ss = self.side_step 334 self._max_left = self._max_line_width - self._inner_width - 1 + ss 335 self._max_left = max(self._max_left, 0) 336 if self._max_left >= self._inner_width - 1 + ss: 337 self._max_left = 0 338 339 def _on_up(self): 340 self._pick = max(self._pick - 1, 0) 341 342 def _on_down(self): 343 limit = max(len(self._entries) - 1, 0) 344 self._pick = min(self._pick + 1, limit) 345 346 def _on_page_up(self): 347 self._pick = max(self._pick - self._inner_height, 0) 348 349 def _on_page_down(self): 350 limit = max(len(self._entries) - 1, 0) 351 self._pick = min(self._pick + self._inner_height, limit) 352 353 def _on_home(self): 354 self._pick = 0 355 356 def _on_end(self): 357 self._pick = max(len(self._entries) - 1, 0) 358 359 def _on_left(self): 360 try: 361 self._change('..') 362 except Exception: 363 pass 364 365 def _on_right(self): 366 e = self._entries[self._pick] 367 if not e[2]: 368 if self.max_view_size > 0 and e[1] <= self.max_view_size: 369 browse_file(join(getcwd(), e[0]), self._screen) 370 else: 371 self._change(e[0]) 372 373 def _on_refresh(self): 374 self._scan() 375 376 377 class TextViewerTUI: 378 ''' 379 This is a scrollable viewer for plain-text content. After initializing it 380 with a TUI screen value, you can configure various fields, before running 381 it by calling method `run`: 382 - title, which is shown at the top in reverse-style 383 - tab_stop, which controls how tabs are turned into spaces 384 - side_step, which controls the speed of lateral side-scrolling 385 - handlers, which has all ncurses key-bindings for the viewer 386 ''' 387 388 def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')): 389 'Optional argument controls which ncurses keys quit the viewer.' 390 391 self.title = '' 392 self.tab_stop = 4 393 self.side_step = 1 394 self.handlers = { 395 'KEY_RESIZE': lambda: self._on_resize(), 396 'KEY_UP': lambda: self._on_up(), 397 'KEY_DOWN': lambda: self._on_down(), 398 'KEY_NPAGE': lambda: self._on_page_down(), 399 'KEY_PPAGE': lambda: self._on_page_up(), 400 'KEY_HOME': lambda: self._on_home(), 401 'KEY_END': lambda: self._on_end(), 402 'KEY_LEFT': lambda: self._on_left(), 403 'KEY_RIGHT': lambda: self._on_right(), 404 } 405 if quit_set: 406 for k in quit_set: 407 self.handlers[k] = None 408 409 self._screen = screen 410 self._inner_width = 0 411 self._inner_height = 0 412 self._max_line_width = 0 413 self._top = 0 414 self._left = 0 415 self._max_top = 0 416 self._max_left = 0 417 self._lines = tuple() 418 419 def run(self, content): 420 'Interactively view/browse the string/strings given.' 421 422 if isinstance(content, BaseException): 423 self._on_resize() 424 self._show_error(content) 425 self._screen.getkey() 426 return 427 428 ts = self.tab_stop 429 if isinstance(content, str): 430 self._lines = tuple(l.expandtabs(ts) for l in content.splitlines()) 431 else: 432 self._lines = tuple(l.expandtabs(ts) for l in content) 433 content = '' # try to deallocate a few MBs when viewing big files 434 435 if len(self._lines) == 0: 436 self._max_line_width = 0 437 else: 438 self._max_line_width = max(len(l) for l in self._lines) 439 self._on_resize() 440 441 iw = self._inner_width 442 ih = self._inner_height 443 444 if iw < 10 or ih < 10: 445 return 446 447 while True: 448 self._redraw() 449 k = self._screen.getkey() 450 if self.handlers and (k in self.handlers): 451 h = self.handlers[k] 452 if (h is None) or (h() is False): 453 self._lines = tuple() 454 return k 455 456 def _fit_string(self, s): 457 maxlen = max(self._inner_width, 0) 458 return s if len(s) <= maxlen else s[:maxlen] 459 460 def _redraw(self): 461 title = self._fit_string(self.title) 462 lines = self._lines 463 screen = self._screen 464 iw = self._inner_width 465 ih = self._inner_height 466 467 if iw < 10 or ih < 10: 468 return 469 470 screen.erase() 471 472 if title: 473 screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE) 474 475 at_bottom = len(self._lines) - self._top <= ih 476 if at_bottom: 477 msg = f'END ({len(lines):,})' if len(lines) > 0 else '(empty)' 478 else: 479 msg = f'({self._top + 1:,} / {len(lines):,})' 480 screen.addstr(0, iw - len(msg), self._fit_string(msg), A_REVERSE) 481 482 for i, l in enumerate(islice(lines, self._top, self._top + ih)): 483 if self._left > 0: 484 l = l[self._left:] 485 try: 486 screen.addnstr(i + 1, 0, l, iw) 487 except Exception: 488 # some utf-8 files have lines which upset func addstr 489 screen.addnstr(i + 1, 0, '?' * len(l), iw) 490 491 # show up/down arrows 492 if self._top > 0: 493 self._screen.addstr(1, iw - 1, '▲') 494 if self._top < self._max_top: 495 self._screen.addstr(ih, iw - 1, '▼') 496 497 screen.refresh() 498 499 def _show_error(self, err): 500 title = self._fit_string(self.title) 501 screen = self._screen 502 iw = self._inner_width 503 ih = self._inner_height 504 505 if iw < 10 or ih < 10: 506 return 507 508 screen.erase() 509 if title: 510 screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE) 511 screen.addstr(2, 0, self._fit_string(str(err)), A_REVERSE) 512 screen.refresh() 513 514 def _on_resize(self): 515 height, width = self._screen.getmaxyx() 516 self._inner_width = width - 1 517 self._inner_height = height - 1 518 self._max_top = max(len(self._lines) - self._inner_height, 0) 519 ss = self.side_step 520 self._max_left = self._max_line_width - self._inner_width - 1 + ss 521 self._max_left = max(self._max_left, 0) 522 523 def _on_up(self): 524 self._top = max(self._top - 1, 0) 525 526 def _on_down(self): 527 self._top = min(self._top + 1, self._max_top) 528 529 def _on_page_up(self): 530 self._top = max(self._top - self._inner_height, 0) 531 532 def _on_page_down(self): 533 self._top = min(self._top + self._inner_height, self._max_top) 534 535 def _on_home(self): 536 self._top = 0 537 538 def _on_end(self): 539 self._top = self._max_top 540 541 def _on_left(self): 542 self._left = max(self._left - self.side_step, 0) 543 544 def _on_right(self): 545 self._left = min(self._left + self.side_step, self._max_left) 546 547 548 def show_help(screen): 549 quit_set = ('\x1b', 'KEY_F(1)', 'KEY_F(10)', 'KEY_F(12)', 'KEY_BACKSPACE') 550 tv = TextViewerTUI(screen, quit_set) 551 tv.title = 'Help for Browse Folders (bf)' 552 return tv.run(info) 553 554 555 def browse_file(name, screen): 556 tv = TextViewerTUI(screen) 557 tv.title = name 558 tv.side_step = 4 559 tv.handlers['KEY_F(1)'] = lambda: show_help(screen) 560 tv.handlers['kLFT5'] = None 561 tv.handlers['KEY_BACKSPACE'] = None 562 return tv.run(slurp(name)) 563 564 565 def run_file_viewer(name): 566 try: 567 if name != '-': 568 tui = SimpleTUI() 569 tui.start(3, 10) 570 browse_file(name, tui.screen) 571 tui.stop() 572 return 0 573 574 # can read piped input only before entering the `ui-mode` 575 text = stdin.read() 576 577 # save memory by clearing the variable holding the slurped string 578 def free_mem(res): 579 nonlocal text 580 text = '' 581 return res 582 583 tui = SimpleTUI() 584 tui.start(3, 10) 585 tv = TextViewerTUI(tui.screen) 586 tv.title = '<stdin>' 587 tv.side_step = 4 588 tv.handlers['KEY_F(1)'] = lambda: show_help(tui.screen) 589 tv.handlers['KEY_BACKSPACE'] = None 590 tv.run(free_mem(text)) 591 tui.stop() 592 return 0 593 except KeyboardInterrupt: 594 return 1 595 except Exception as e: 596 tui.stop() 597 print(str(e), file=stderr) 598 return 1 599 600 601 def run_folder_browser(name, max_view_size=100*1024**2): 602 tui = SimpleTUI() 603 quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b') 604 605 try: 606 tui.start(3, 10) 607 fb = FolderBrowserTUI(tui.screen, quit_set) 608 fb.max_view_size = max_view_size 609 fb.handlers['KEY_F(1)'] = lambda: show_help(tui.screen) 610 fb.handlers['kRIT5'] = lambda: fb._on_right() 611 fb.handlers['\t'] = lambda: fb._on_right() 612 fb.handlers['KEY_BACKSPACE'] = lambda: fb._on_left() 613 fb.handlers['kLFT5'] = lambda: fb._on_left() 614 pick, last = fb.run(name) 615 except KeyboardInterrupt: 616 tui.stop() 617 return 1 618 except Exception as e: 619 tui.stop() 620 print(str(e), file=stderr) 621 return 1 622 623 tui.stop() 624 dup2(3, 1) 625 626 if last is None or last in quit_set or not pick: 627 return 1 628 629 print(join(getcwd(), pick)) 630 return 0 631 632 633 def show_help(screen): 634 quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b', 'KEY_F(1)') 635 h = TextViewerTUI(screen, quit_set) 636 h.title = 'Help for Browse Folders (bf)' 637 return h.run(info) != '\x1b' 638 639 640 def slurp(name): 641 try: 642 with open(name, 'r') as inp: 643 return inp.read() 644 except Exception as e: 645 return e 646 647 648 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'): 649 print(info.strip()) 650 exit(0) 651 652 if len(argv) > 2: 653 msg = 'there can only be one (optional) starting-folder argument' 654 print(msg, file=stderr) 655 exit(4) 656 657 # avoid func curses.wrapper, since it calls func curses.start_color, which in 658 # turn forces a black background no matter the terminal configuration 659 660 if len(argv) == 2: 661 run = run_folder_browser if isdir(argv[1]) else run_file_viewer 662 exit(run(argv[1])) 663 else: 664 exit(run_folder_browser('.'))