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