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