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