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('.'))