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