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