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