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