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