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