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, A_ITALIC, 29 ) 30 from os import dup2, getcwd 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 When the optional command-line argument is a filename (instead of a starting 44 folder), it becomes a basic (UTF-8) plain-text file viewer for that file. 45 46 47 Enter Quit this app, emitting the currently-selected entry 48 Escape Quit this app without emitting an entry; quit file viewers 49 F1 Toggle help-message screen; the Escape key also quits it 50 F5 Update current view, in case current file/folder has changed 51 F6 Toggle name/size sorting, and update current-folder entries 52 F10 / F12 Quit this app without emitting an entry; quit file viewers 53 54 Left View the current folder's parent folder 55 Right View the currently-selected folder 56 Backspace View the current folder's parent folder; exit file viewers 57 Tab View the currently-selected folder 58 59 Home Select the first entry in the current folder 60 End Select the last entry in the current folder 61 Up Select the entry before the currently selected one 62 Down Select the entry after the currently selected one 63 Page Up Select entry by jumping one screen backward 64 Page Down Select entry by jumping one screen forward 65 66 [Other] Jump to the first/next entry whose name starts with that 67 letter or digit; letters are matched case-insensitively 68 69 70 Escape quits the app without emitting the currently-selected item and with 71 an error-code, while Enter emits the selected item, quitting successfully. 72 73 Folders are shown without a file-size, and are always shown before files. 74 75 Some file/folder entries may be special and/or give an error when queried 76 for their file-size: these are shown with a question mark where the sizes 77 would normally be. 78 79 The right side of the screen also shows little up/down arrow symbols when 80 there are more entries before/after the ones currently showing. 81 82 When things have changed in the current folder, you can press the F5 key 83 to reload the entries on screen, so there's no need to manually get out 84 and back into the current folder as a workaround. 85 86 All (optional) leading options start with either single or double-dash: 87 88 -h, -help show this help message 89 ''' 90 91 92 class SimpleTUI: 93 ''' 94 Manager to start/stop a no-color text user-interface (TUI), allowing for 95 standard input/output to be used normally before method `start` is called 96 and after method `stop` is called. 97 ''' 98 99 def __init__(self): 100 self.screen = None 101 102 def start(self, out_fd = -1, esc_delay = -1): 103 ''' 104 Start interactive-mode: the first optional argument should be more 105 than 2, if given, since it would mess with stdio, which is precisely 106 what it's meant to avoid doing. 107 ''' 108 109 if out_fd >= 0: 110 from os import dup2 111 112 # keep original stdout as /dev/fd/... 113 dup2(1, out_fd) 114 # separate live output from final (optional) result on stdout 115 with open('/dev/tty', 'rb') as inp, open('/dev/tty', 'wb') as out: 116 dup2(inp.fileno(), 0) 117 dup2(out.fileno(), 1) 118 119 self.screen = initscr() 120 savetty() 121 noecho() 122 cbreak() 123 self.screen.keypad(True) 124 curs_set(0) 125 if esc_delay >= 0: 126 set_escdelay(esc_delay) 127 128 def stop(self): 129 'Stop interactive-mode.' 130 if self.screen: 131 resetty() 132 endwin() 133 134 135 class FolderBrowserTUI: 136 ''' 137 This is a scrollable viewer to browse folders. After initializing it with 138 a TUI screen value, you can configure various fields before calling its 139 method `run`: 140 - max_view_size, which limits of big (in bytes) text files can be 141 viewed/loaded; negative values disables text-viewer functionality 142 - side_step, which controls the speed of lateral side-scrolling 143 - handlers, which has all ncurses key-bindings for the viewer 144 ''' 145 146 def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')): 147 'Optional argument controls which ncurses keys quit the viewer.' 148 149 self.help = '' 150 self.sort_size = False 151 self.max_view_size = -1 152 self.side_step = 1 153 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(1)': lambda: self._show_help(), 165 'KEY_F(5)': lambda: self._on_refresh(), 166 'KEY_F(6)': lambda: self._on_sort(), 167 } 168 169 if quit_set: 170 for k in quit_set: 171 self.handlers[k] = None 172 173 self._screen = screen 174 self._inner_width = 0 175 self._inner_height = 0 176 self._max_line_width = 0 177 self._pick = 0 178 self._max_top = 0 179 self._max_left = 0 180 self._current_folder = '' 181 self._entries = tuple() 182 self._trail = [] 183 self.pick = None 184 185 def run(self, folder): 186 'Interactively view/browse folders, starting from the path given.' 187 188 self._change(folder) 189 self._on_resize() 190 191 while True: 192 self._redraw() 193 k = self._screen.getkey() 194 195 if k == '\n': 196 e = self._entries 197 pick = e[self._pick][0] if len(e) else None 198 self._entries = tuple() 199 return (pick, k) 200 201 if k in self.handlers: 202 h = self.handlers[k] 203 if h is None: 204 self._entries = tuple() 205 return ('', None) 206 if h() is False: 207 pick = self._entries[self._pick][0] 208 self._entries = tuple() 209 return (pick, None) 210 elif len(k) == 1: 211 i = self._seek(k, self._pick + 1) 212 if i < 0: 213 i = self._seek(k, 0) 214 if i >= 0: 215 self._pick = i 216 217 def _browse_file(self, name): 218 tv = TextViewerTUI(self._screen) 219 tv.title = name 220 tv.file_name = name 221 tv.side_step = 4 222 tv.handlers['KEY_F(1)'] = lambda: self._show_help() 223 tv.handlers['\x1b'] = None 224 tv.handlers['KEY_F(10)'] = None 225 tv.handlers['KEY_F(12)'] = None 226 tv.handlers['KEY_BACKSPACE'] = None 227 228 def maybe_string(data): 229 try: 230 if isinstance(data, BaseException): 231 return data 232 return data.decode('utf-8') 233 except UnicodeDecodeError as _: 234 return data 235 except BaseException as e: 236 raise e 237 238 return tv.run(maybe_string(self._slurp(name))) 239 240 def _change(self, folder): 241 from os import chdir, getcwd 242 243 if folder == '..': 244 chdir(folder) 245 self._current_folder = getcwd() 246 self._scan() 247 if len(self._trail) > 0: 248 self._pick_name(self._trail[:len(self._trail) - 1], 0) 249 self._trail.pop() 250 return 251 252 if len(self._entries) > 0: 253 self._trail.extend(self._entries[self._pick][0]) 254 chdir(folder) 255 self._current_folder = getcwd() 256 self._trail.append(folder) 257 self._scan() 258 self._pick = 0 259 260 def _fit_string(self, s): 261 maxlen = max(self._inner_width, 0) 262 return s if len(s) <= maxlen else s[:maxlen] 263 264 def _pick_name(self, name, fallback = 0): 265 self._pick = fallback 266 for i, e in enumerate(self._entries): 267 if e[0] == name: 268 self._pick = i 269 return 270 271 def _redraw(self): 272 title = self._fit_string(self._current_folder) 273 entries = self._entries 274 screen = self._screen 275 iw = self._inner_width 276 ih = self._inner_height 277 278 if iw < 10 or ih < 10: 279 return 280 281 screen.erase() 282 283 if title: 284 screen.addstr(0, 0, f'{self._current_folder:<{iw}}') 285 286 if isinstance(entries, BaseException): 287 screen.addstr(2, 0, f'{str(entries):<{iw}}', A_REVERSE) 288 screen.refresh() 289 return 290 291 start = self._pick - (self._pick % ih) 292 stop = start + ih 293 294 from math import ceil, log10 295 296 if len(entries) > 0: 297 w = int(ceil(log10(len(entries)))) 298 msg = f'({self._pick + 1:>{w},} / {len(entries):,})' 299 else: 300 msg = '(empty)' 301 screen.addstr(0, iw - len(msg), self._fit_string(msg)) 302 303 from itertools import islice 304 305 for i, e in enumerate(islice(entries, start, stop)): 306 try: 307 if not e[2]: 308 if e[1] >= 0: 309 screen.addnstr(i + 1, 0, f'{e[1]:15,}', iw, A_NORMAL) 310 else: 311 screen.addnstr(i + 1, 14, '?', iw, A_NORMAL) 312 style = A_REVERSE if i == self._pick % ih else A_NORMAL 313 if e[3]: 314 s = f'{e[0]} -> {e[4]}' 315 style = style | A_UNDERLINE | A_ITALIC 316 else: 317 s = e[0] 318 indent = 17 319 screen.addnstr(i + 1, indent, s, iw - indent, style) 320 except Exception as e: 321 # some utf-8 files have lines which upset func addstr 322 screen.addnstr(i + 1, 0, '?' * len(e[0]), iw, style) 323 324 # show up/down arrows 325 s = 'â–²' if self._pick >= ih and len(entries) > 0 else ' ' 326 self._screen.addstr(1, iw - 1, s) 327 i = self._pick + ih 328 s = 'â–¼' if i < len(entries) and len(entries) > 0 else ' ' 329 s = 'â–¼' if start + ih < len(entries) and len(entries) > 0 else ' ' 330 self._screen.addstr(ih, iw - 1, s) 331 332 screen.refresh() 333 334 def _scan(self): 335 from os import readlink, scandir 336 337 def safe_size(e): 338 try: 339 return e.stat().st_size 340 except Exception as _: 341 return -1 342 343 def f(e): 344 folder = e.is_dir() 345 link = e.is_symlink() 346 size = 0 if folder else safe_size(e) 347 path = e.path.removeprefix('./') 348 target = readlink(path) if link else '' 349 return (path, size, folder, link, target) 350 351 def name_key(e): 352 name, _, folder, _, _ = e 353 return (not folder, name) 354 355 def size_key(e): 356 name, size, folder, _, _ = e 357 return (not folder, -size, name) 358 359 key = size_key if self.sort_size else name_key 360 try: 361 self._entries = sorted((f(e) for e in scandir()), key=key) 362 except Exception as e: 363 self._entries = e 364 self._max_line_width = len(str(e)) 365 return 366 367 if len(self._entries) > 0: 368 self._max_line_width = max(len(e[0]) for e in self._entries) 369 else: 370 self._max_line_width = 0 371 372 def _seek(self, k, start): 373 from itertools import islice 374 375 if len(k) != 1: 376 return -1 377 378 k = k.lower() 379 for i, e in enumerate(islice(self._entries, start, None)): 380 name = e[0] 381 if name.startswith(k) or name.lower().startswith(k): 382 return start + i 383 return -1 384 385 def _show_help(self): 386 if not self.help: 387 return 388 389 from sys import argv 390 name = argv[0] 391 pieces = name.split('/') 392 if len(pieces) > 1: 393 name = pieces[-1] 394 qs = ('\x1b', 'KEY_F(1)', 'KEY_F(10)', 'KEY_F(12)', 'KEY_BACKSPACE') 395 tv = TextViewerTUI(self._screen, qs) 396 tv.title = f'Help for {name}' 397 return tv.run(info) == 'KEY_F(1)' 398 399 def _show_error(self, title, err): 400 title = self._fit_string(title) 401 screen = self._screen 402 iw = self._inner_width 403 ih = self._inner_height 404 405 if iw < 10 or ih < 10: 406 return 407 408 screen.erase() 409 if title: 410 screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE) 411 screen.addstr(2, 0, self._fit_string(str(err)), A_REVERSE) 412 screen.refresh() 413 screen.getkey() 414 415 def _slurp(self, name): 416 try: 417 with open(name, 'r') as inp: 418 for _ in inp: 419 break 420 except UnicodeDecodeError as _: 421 with open(name, 'rb') as inp: 422 return inp.read(1024) 423 except Exception as e: 424 return e 425 try: 426 with open(name, 'rb') as inp: 427 return inp.read() 428 except Exception as e: 429 return e 430 431 def _on_resize(self): 432 height, width = self._screen.getmaxyx() 433 self._inner_width = width - 1 434 self._inner_height = height - 1 435 self._max_top = max(len(self._entries) - self._inner_height, 0) 436 ss = self.side_step 437 self._max_left = self._max_line_width - self._inner_width - 1 + ss 438 self._max_left = max(self._max_left, 0) 439 if self._max_left >= self._inner_width - 1 + ss: 440 self._max_left = 0 441 442 def _on_sort(self): 443 i = self._pick 444 s = self._entries[i][0] if 0 <= i < len(self._entries) else '' 445 self.sort_size = not self.sort_size 446 self._scan() 447 self._pick = 0 448 for i, e in enumerate(self._entries): 449 if e[0] == s: 450 self._pick = i 451 break 452 453 def _on_up(self): 454 self._pick = max(self._pick - 1, 0) 455 456 def _on_down(self): 457 limit = max(len(self._entries) - 1, 0) 458 self._pick = min(self._pick + 1, limit) 459 460 def _on_page_up(self): 461 self._pick = max(self._pick - self._inner_height, 0) 462 463 def _on_page_down(self): 464 limit = max(len(self._entries) - 1, 0) 465 self._pick = min(self._pick + self._inner_height, limit) 466 467 def _on_home(self): 468 self._pick = 0 469 470 def _on_end(self): 471 self._pick = max(len(self._entries) - 1, 0) 472 473 def _on_left(self): 474 try: 475 if len(self._trail) == 0: 476 self._change('..') 477 return 478 479 s = self._trail[-1] 480 self._change('..') 481 self._pick = 0 482 for i, e in enumerate(self._entries): 483 if e[0] == s: 484 self._pick = i 485 break 486 except Exception as _: 487 pass 488 489 def _on_right(self): 490 from os import getcwd 491 from os.path import join 492 493 if len(self._entries) == 0: 494 return 495 496 e = self._entries[self._pick] 497 if e[2]: 498 self._change(e[0]) 499 return 500 501 name = join(getcwd(), e[0]) 502 503 if self.max_view_size > 0 and e[1] <= self.max_view_size: 504 quit_set = ('\x1b', 'KEY_F(10)', 'KEY_F(12)') 505 return not (self._browse_file(name) in quit_set) 506 507 msg = 'file is too big to view: this is an explicit app limit' 508 msg = f'{msg} ({self.max_view_size:,} bytes)' 509 self._show_error(name, BaseException(msg)) 510 511 def _on_refresh(self): 512 self._scan() 513 514 515 class TextViewerTUI: 516 ''' 517 This is a scrollable viewer for plain-text content. After initializing it 518 with a TUI screen value, you can configure various fields, before running 519 it by calling method `run`: 520 - title, which is shown at the top in reverse-style 521 - tab_stop, which controls how tabs are turned into spaces 522 - side_step, which controls the speed of lateral side-scrolling 523 - handlers, which has all ncurses key-bindings for the viewer 524 ''' 525 526 def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')): 527 'Optional argument controls which ncurses keys quit the viewer.' 528 529 self.title = '' 530 self.file_name = '' 531 self.tab_stop = 4 532 self.side_step = 1 533 534 self.handlers = { 535 'KEY_RESIZE': lambda: self._on_resize(), 536 'KEY_UP': lambda: self._on_up(), 537 'KEY_DOWN': lambda: self._on_down(), 538 'KEY_NPAGE': lambda: self._on_page_down(), 539 'KEY_PPAGE': lambda: self._on_page_up(), 540 'KEY_HOME': lambda: self._on_home(), 541 'KEY_END': lambda: self._on_end(), 542 'KEY_LEFT': lambda: self._on_left(), 543 'KEY_RIGHT': lambda: self._on_right(), 544 'KEY_F(5)': lambda: self._on_reload(), 545 } 546 547 if quit_set: 548 for k in quit_set: 549 self.handlers[k] = None 550 551 self._screen = screen 552 self._inner_width = 0 553 self._inner_height = 0 554 self._max_line_width = 0 555 self._top = 0 556 self._left = 0 557 self._max_top = 0 558 self._max_left = 0 559 self._lines = tuple() 560 561 def load_file(self): 562 if self.file_name == '': 563 return 564 565 with open(self.file_name, 'r') as inp: 566 content = inp.read() 567 self.split_lines(content) 568 569 mlw = 0 570 if len(self._lines) > 0: 571 mlw = max(len(l) for l in self._lines) 572 self._max_line_width = mlw 573 574 def split_lines(self, content): 575 ts = self.tab_stop 576 if isinstance(content, str): 577 self._lines = tuple(l.expandtabs(ts) for l in content.splitlines()) 578 else: 579 self._lines = tuple(l.expandtabs(ts) for l in content) 580 581 def run(self, content = None): 582 'Interactively view/browse the string/strings given.' 583 584 if isinstance(content, bytes): 585 self._on_resize() 586 return self._run_bin(content) 587 588 if isinstance(content, BaseException): 589 self._on_resize() 590 self._show_error(content) 591 return self._screen.getkey() 592 593 if content is None: 594 self.load_file() 595 else: 596 self.split_lines(content) 597 content = '' # try to deallocate a few MBs when viewing big files 598 599 self._on_resize() 600 601 iw = self._inner_width 602 ih = self._inner_height 603 604 if iw < 10 or ih < 10: 605 return 606 607 while True: 608 self._redraw() 609 k = self._screen.getkey() 610 if self.handlers and (k in self.handlers): 611 h = self.handlers[k] 612 if (h is None) or (h() is False): 613 self._lines = tuple() 614 return k 615 616 def _run_bin(self, header): 617 # header = header[:128] 618 title = self._fit_string(self.title) 619 screen = self._screen 620 iw = self._inner_width 621 ih = self._inner_height 622 623 if iw < 10 or ih < 10: 624 return None 625 626 screen.erase() 627 kind = self._detect_type(header) 628 if title: 629 screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE) 630 screen.addstr(2, 0, self._fit_string(kind), A_REVERSE) 631 screen.refresh() 632 return self._screen.getkey() 633 634 def _fit_string(self, s): 635 maxlen = max(self._inner_width, 0) 636 return s if len(s) <= maxlen else s[:maxlen] 637 638 def _redraw(self): 639 title = self._fit_string(self.title) 640 lines = self._lines 641 screen = self._screen 642 iw = self._inner_width 643 ih = self._inner_height 644 645 if iw < 10 or ih < 10: 646 return 647 648 screen.erase() 649 650 if title: 651 screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE) 652 653 from math import ceil, log10 654 655 at_bottom = len(self._lines) - self._top <= ih 656 w = int(ceil(log10(len(lines)))) if len(lines) > 0 else 1 657 if at_bottom: 658 msg = '(empty)' 659 if len(lines) > 0: 660 msg = f'END ({self._top + 1:>{w},} / {len(lines):,})' 661 else: 662 msg = f'({self._top + 1:>{w},} / {len(lines):,})' 663 screen.addstr(0, iw - len(msg), self._fit_string(msg), A_REVERSE) 664 665 from itertools import islice 666 667 for i, l in enumerate(islice(lines, self._top, self._top + ih)): 668 if self._left > 0: 669 l = l[self._left:] 670 try: 671 screen.addnstr(i + 1, 0, l, iw) 672 except Exception as _: 673 # some utf-8 files have lines which upset func addstr 674 screen.addnstr(i + 1, 0, '?' * len(l), iw) 675 676 # show up/down arrows 677 if self._top > 0: 678 self._screen.addstr(1, iw - 1, 'â–²') 679 if self._top < self._max_top: 680 self._screen.addstr(ih, iw - 1, 'â–¼') 681 682 screen.refresh() 683 684 def _show_error(self, err): 685 title = self._fit_string(self.title) 686 screen = self._screen 687 iw = self._inner_width 688 ih = self._inner_height 689 690 if iw < 10 or ih < 10: 691 return 692 693 screen.erase() 694 if title: 695 screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE) 696 screen.addstr(2, 0, self._fit_string(str(err)), A_REVERSE) 697 screen.refresh() 698 699 def _on_resize(self): 700 height, width = self._screen.getmaxyx() 701 self._inner_width = width - 1 702 self._inner_height = height - 1 703 self._max_top = max(len(self._lines) - self._inner_height, 0) 704 ss = self.side_step 705 self._max_left = self._max_line_width - self._inner_width - 1 + ss 706 self._max_left = max(self._max_left, 0) 707 708 def _on_up(self): 709 self._top = max(self._top - 1, 0) 710 711 def _on_down(self): 712 self._top = min(self._top + 1, self._max_top) 713 714 def _on_page_up(self): 715 self._top = max(self._top - self._inner_height, 0) 716 717 def _on_page_down(self): 718 self._top = min(self._top + self._inner_height, self._max_top) 719 720 def _on_home(self): 721 self._top = 0 722 723 def _on_end(self): 724 self._top = self._max_top 725 726 def _on_left(self): 727 self._left = max(self._left - self.side_step, 0) 728 729 def _on_right(self): 730 self._left = min(self._left + self.side_step, self._max_left) 731 732 def _on_reload(self): 733 self.load_file() 734 735 def _detect_type(self, header): 736 hdr_dispatch = { 737 0x00: [ 738 (b'\x00\x00\x01\xba', 'video/mpeg'), 739 (b'\x00\x00\x01\xb3', 'video/mpeg'), 740 (b'\x00\x00\x01\x00', 'image/x-icon'), 741 (b'\x00\x00\x02\x00', 'image/vnd.microsoft.icon'), # .cur files 742 (b'\x00asm', 'application/wasm'), 743 ], 744 0x1a: [(b'\x1a\x45\xdf\xa3', 'video/webm')], # general MKV format 745 0x1f: [(b'\x1f\x8b\x08', 'application/gzip')], 746 0x23: [ 747 (b'#! ', 'text/plain; charset=UTF-8'), 748 (b'#!/', 'text/plain; charset=UTF-8'), 749 ], 750 0x25: [ 751 (b'%PDF', 'application/pdf'), 752 (b'%!PS', 'application/postscript'), 753 ], 754 0x28: [(b'\x28\xb5\x2f\xfd', 'application/zstd')], 755 0x2e: [(b'.snd', 'audio/basic')], 756 0x47: [(b'GIF87a', 'image/gif'), (b'GIF89a', 'image/gif')], 757 0x49: [ 758 # some MP3s start with an ID3 meta-data section 759 (b'ID3\x02', 'audio/mpeg'), (b'ID3\x03', 'audio/mpeg'), 760 (b'ID3\x04', 'audio/mpeg'), (b'II*\x00', 'image/tiff'), 761 ], 762 0x4d: [(b'MM\x00*', 'image/tiff'), (b'MThd', 'audio/midi')], 763 0x4f: [(b'OggS', 'audio/ogg')], 764 0x50: [(b'PK\x03\x04', 'application/zip')], 765 0x53: [(b'SQLite format 3\x00', 'application/x-sqlite3')], 766 0x63: [(b'caff\x00\x01\x00\x00', 'audio/x-caf')], 767 0x66: [(b'fLaC', 'audio/x-flac')], 768 0x7b: [(b'{\\rtf', 'application/rtf')], 769 0x7f: [(b'\x7fELF', 'application/x-elf')], 770 0x89: [(b'\x89PNG\x0d\x0a\x1a\x0a', 'image/png')], 771 0xff: [ 772 (b'\xff\xd8\xff', 'image/jpeg'), 773 # handle common ways MP3 data start 774 (b'\xff\xf3\x48\xc4\x00', 'audio/mpeg'), 775 (b'\xff\xfb', 'audio/mpeg'), 776 ], 777 } 778 779 # ftyp_types helps func match_ftyp auto-detect MPEG-4-like formats 780 ftyp_types = ( 781 (b'M4A ', 'audio/aac'), 782 (b'M4A\x00', 'audio/aac'), 783 (b'mp42', 'video/x-m4v'), 784 (b'dash', 'audio/aac'), 785 # (b'isom', 'audio/aac'), 786 (b'isom', 'video/mp4'), (b'MSNV', 'video/mp4'), 787 (b'qt ', 'video/quicktime'), 788 (b'heic', 'image/heic'), 789 (b'avif', 'image/avif'), 790 ) 791 792 xmlish_heuristics = ( 793 (b'<html>', 'text/html'), (b'<html ', 'text/html'), 794 (b'<head>', 'text/html'), (b'<head ', 'text/html'), 795 (b'<body>', 'text/html'), (b'<body ', 'text/html'), 796 (b'<!DOCTYPE html', 'text/html'), 797 (b'<svg>', 'image/svg+xml'), (b'<svg ', 'image/svg+xml'), 798 (b'<?xml>', 'application/xml'), (b'<?xml ', 'application/xml'), 799 ) 800 801 from re import compile as comp 802 803 json_heuristics = ( 804 comp(b'''^\\s*\\{\\s*"'''), comp(b'''^\\s*\\{\\s*\\['''), 805 comp(b'''^\\s*\\[\\s*"'''), comp(b'''^\\s*\\[\\s*\\{'''), 806 comp(b'''^\\s*\\[\\s*\\['''), 807 ) 808 809 def exact_match(header: bytes, maybe: bytes) -> bool: 810 enough_bytes = len(header) >= len(maybe) 811 return enough_bytes and all(x == y for x, y in zip(header, maybe)) 812 813 def match_riff(header: bytes) -> str: 814 if len(header) < 12 or not header.startswith(b'RIFF'): 815 return '' 816 817 if header.find(b'WEBP', 8, 12) == 8: 818 return 'image/webp' 819 if header.find(b'WAVE', 8, 12) == 8: 820 return 'audio/x-wav' 821 if header.find(b'AVI ', 8, 12) == 8: 822 return 'video/avi' 823 return '' 824 825 def match_form(header: bytes) -> str: 826 if len(header) < 12 or not header.startswith(b'FORM'): 827 return '' 828 829 if header.find(b'AIFF', 8, 12) == 8: 830 return 'audio/aiff' 831 if header.find(b'AIFC', 8, 12) == 8: 832 return 'audio/aiff' 833 return '' 834 835 def match_ftyp(header: bytes) -> str: 836 # first 4 bytes can be anything, next 4 bytes must be ASCII 'ftyp' 837 if len(header) < 12 or header.find(b'ftyp', 4, 8) != 4: 838 return '' 839 840 # next 4 bytes after the ASCII 'ftyp' declare the data-format 841 for marker, mime in ftyp_types: 842 if header.find(marker, 8, 12) == 8: 843 return mime 844 845 return '' 846 847 848 def guess_mime(header: bytes, fallback: str) -> str: 849 # no bytes, no match 850 if len(header) == 0: 851 return fallback 852 853 # check the MPEG-4-like formats, the RIFF formats, and AIFF audio 854 for f in (match_ftyp, match_riff, match_form): 855 m = f(header) 856 if m != '': 857 return m 858 859 # maybe it's a bitmap picture, which usually has 40 on 15th byte 860 if header.startswith(b'BM') and header.find(b'\x28', 8, 16) == 14: 861 return 'image/x-bmp' 862 863 # check general lookup-table 864 if header[0] in hdr_dispatch: 865 for maybe in hdr_dispatch[header[0]]: 866 if exact_match(header, maybe[0]): 867 return maybe[1] 868 869 # try HTML, SVG, and even generic XML 870 if header.find(b'<', 0, 8) >= 0: 871 for marker, mime in xmlish_heuristics: 872 if header.find(marker, 0, 64) >= 0: 873 return mime 874 875 # try some common cases for JSON 876 for pattern in json_heuristics: 877 if pattern.match(header): 878 return 'application/json' 879 880 # nothing matched 881 return fallback 882 883 return guess_mime(header, 'application/octet-stream') 884 885 886 def show_help(screen): 887 quit_set = ('\x1b', 'KEY_F(1)', 'KEY_F(10)', 'KEY_F(12)', 'KEY_BACKSPACE') 888 h = TextViewerTUI(screen, quit_set) 889 h.title = 'Help for Browse Folders (bf)' 890 return h.run(info) == 'KEY_F(1)' 891 892 893 def browse_file(name, screen): 894 tv = TextViewerTUI(screen) 895 tv.title = name 896 tv.side_step = 4 897 tv.file_name = name 898 tv.handlers['KEY_F(1)'] = lambda: show_help(screen) 899 tv.handlers['kLFT5'] = None 900 tv.handlers['KEY_BACKSPACE'] = None 901 return tv.run() 902 903 904 def run_file_viewer(name): 905 try: 906 tui = SimpleTUI() 907 tui.start(3, 10) 908 if name == '-': 909 return run_stdin_viewer(tui) 910 browse_file(name, tui.screen) 911 tui.stop() 912 return 0 913 except KeyboardInterrupt: 914 return 1 915 except Exception as e: 916 tui.stop() 917 # raise e 918 print(str(e), file=stderr) 919 return 1 920 921 922 def run_stdin_viewer(tui): 923 # can read piped input only before entering the `ui-mode` 924 text = stdin.read() 925 926 # save memory by clearing the variable holding the slurped string 927 def free_mem(res): 928 nonlocal text 929 text = '' 930 return res 931 932 tv = TextViewerTUI(tui.screen) 933 tv.title = '<stdin>' 934 tv.side_step = 4 935 tv.handlers['KEY_F(1)'] = lambda: show_help(tui.screen) 936 tv.handlers['KEY_BACKSPACE'] = None 937 tv.run(free_mem(text)) 938 tui.stop() 939 return 0 940 941 942 def run_folder_browser(name, max_view_size=256*1024**2): 943 tui = SimpleTUI() 944 quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b') 945 946 try: 947 tui.start(3, 10) 948 fb = FolderBrowserTUI(tui.screen, quit_set) 949 fb.help = info 950 fb.max_view_size = max_view_size 951 fb.handlers['KEY_F(1)'] = lambda: show_help(tui.screen) 952 fb.handlers['kRIT5'] = lambda: fb._on_right() 953 fb.handlers['\t'] = lambda: fb._on_right() 954 fb.handlers['KEY_BACKSPACE'] = lambda: fb._on_left() 955 fb.handlers['kLFT5'] = lambda: fb._on_left() 956 pick, last = fb.run(name) 957 except KeyboardInterrupt: 958 tui.stop() 959 return 1 960 except Exception as e: 961 tui.stop() 962 print(str(e), file=stderr) 963 return 1 964 965 tui.stop() 966 dup2(3, 1) 967 968 if last is None or last in quit_set or not pick: 969 return 1 970 971 print(join(getcwd(), pick)) 972 return 0 973 974 975 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'): 976 print(info.strip()) 977 exit(0) 978 979 args = argv[1:] 980 981 if len(args) > 0: 982 if args[0] == '--': 983 args = args[1:] 984 elif args[0] != '-' and args[0].startswith('-'): 985 print(f'bf: unsupported option {args[0]}', file=stderr) 986 exit(1) 987 988 if len(args) > 1: 989 msg = 'there can only be one (optional) starting-folder argument' 990 print(msg, file=stderr) 991 exit(4) 992 993 # avoid func curses.wrapper, since it calls func curses.start_color, which in 994 # turn forces a black background no matter the terminal configuration 995 996 if len(args) == 1: 997 run = run_folder_browser if isdir(args[0]) else run_file_viewer 998 exit(run(args[0])) 999 else: 1000 exit(run_folder_browser('.'))