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,
  29     A_DIM, A_ITALIC
  30 )
  31 from itertools import islice
  32 from os import chdir, dup2, getcwd, readlink, scandir
  33 from os.path import join, isdir
  34 from sys import argv, stderr, stdin
  35 
  36 
  37 info = '''
  38 bf [options...] [file/folder...]
  39 
  40 
  41 Browse Folders is a text user-interface (TUI) to do just that. By default
  42 it starts browsing from the current folder, but you can choose a different
  43 starting point as an optional cmd-line argument when starting this script.
  44 
  45 It's also a (UTF-8) plain-text file viewer; when the optional command-line
  46 argument is a filename (instead of a starting folder) it acts purely as a
  47 viewer for the file given.
  48 
  49 
  50     Enter      Quit this app, emitting the currently-selected entry
  51     Escape     Quit this app without emitting an entry; quit text viewers
  52     F1         Toggle help-message screen; the Escape key also quits it
  53     F5         Update current-folder entries, in case they've changed
  54     F10        Quit this app without emitting an entry; quit text viewers
  55     F12        Quit this app without emitting an entry; quit text viewers
  56 
  57     Left       Go to the current folder's parent folder
  58     Right      Go to the currently-selected folder
  59     Backspace  Go to the current folder's parent folder
  60     Tab        Go to the currently-selected folder
  61 
  62     Home       Select the first entry in the current folder
  63     End        Select the last entry in the current folder
  64     Up         Select the entry before the currently selected one
  65     Down       Select the entry after the currently selected one
  66     Page Up    Select entry by jumping one screen backward
  67     Page Down  Select entry by jumping one screen forward
  68 
  69     [Other]    Jump to the first/next entry whose name starts with that
  70                letter or digit; letters are matched case-insensitively
  71 
  72 
  73 Escape quits the app without emitting the currently-selected item and with
  74 an error-code, while Enter emits the selected item, quitting successfully.
  75 
  76 Folders are shown without a file-size, and are always shown before files.
  77 
  78 Some file/folder entries may be special and/or give an error when queried
  79 for their file-size: these are shown with a question mark where their size
  80 would normally be.
  81 
  82 The right side of the screen also shows little up/down arrow symbols when
  83 there are more entries before/after the ones currently showing.
  84 
  85 The only options are one of the help options, which show this message:
  86 these are `-h`, `--h`, `-help`, and `--help`, without the quotes.
  87 
  88 You can also view this help message by pressing the F1 key while browsing
  89 folders: the help viewer works the same as the text-file viewer; you can
  90 quit the help screen with the Escape key, or with the F1 key.
  91 
  92 When things have changed in the current folder, you can press the F5 key
  93 to reload the entries on screen, so there's no need to manually get out
  94 and back into the current folder as a workaround.
  95 '''
  96 
  97 
  98 class SimpleTUI:
  99     '''
 100     Manager to start/stop a no-color text user-interface (TUI), allowing for
 101     standard input/output to be used normally before method `start` is called
 102     and after method `stop` is called. After calling is method `start`, its
 103     field `screen` has the ncurses value for all the interactive input-output.
 104     '''
 105 
 106     def __init__(self):
 107         self.screen = None
 108 
 109     def start(self, out_fd = -1, esc_delay = -1):
 110         '''
 111         Start interactive-mode: the first optional argument should be more
 112         than 2, if given, since it would mess with stdio, which is precisely
 113         what it's meant to avoid doing.
 114         '''
 115 
 116         if out_fd >= 0:
 117             # keep original stdout as /dev/fd/...
 118             dup2(1, out_fd)
 119             # separate live output from final (optional) result on stdout
 120             with open('/dev/tty', 'rb') as inp, open('/dev/tty', 'wb') as out:
 121                 dup2(inp.fileno(), 0)
 122                 dup2(out.fileno(), 1)
 123 
 124         self.screen = initscr()
 125         savetty()
 126         noecho()
 127         cbreak()
 128         self.screen.keypad(True)
 129         curs_set(0)
 130         if esc_delay >= 0:
 131             set_escdelay(esc_delay)
 132 
 133     def stop(self):
 134         'Stop interactive-mode.'
 135         if self.screen:
 136             resetty()
 137             endwin()
 138 
 139 
 140 class FolderBrowserTUI:
 141     '''
 142     This is a scrollable viewer to browse folders. After initializing it with
 143     a TUI screen value, you can configure various fields before calling its
 144     method `run`:
 145         - max_view_size, which limits of big (in bytes) text files can be
 146             viewed/loaded; negative values disables text-viewer functionality
 147         - side_step, which controls the speed of lateral side-scrolling
 148         - handlers, which has all ncurses key-bindings for the viewer
 149     '''
 150 
 151     def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')):
 152         'Optional argument controls which ncurses keys quit the viewer.'
 153 
 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(5)': lambda: self._on_refresh(),
 167         }
 168         if quit_set:
 169             for k in quit_set:
 170                 self.handlers[k] = None
 171 
 172         self._screen = screen
 173         self._inner_width = 0
 174         self._inner_height = 0
 175         self._max_line_width = 0
 176         self._pick = 0
 177         self._max_top = 0
 178         self._max_left = 0
 179         self._current_folder = ''
 180         self._entries = tuple()
 181         self._trail = []
 182         self.pick = None
 183 
 184     def run(self, folder):
 185         'Interactively view/browse folders, starting from the path given.'
 186 
 187         self._change(folder)
 188         self._on_resize()
 189 
 190         while True:
 191             self._redraw()
 192             k = self._screen.getkey()
 193             if k == '\n':
 194                 e = self._entries
 195                 pick = e[self._pick][0] if len(e) else None
 196                 self._entries = tuple()
 197                 return (pick, k)
 198 
 199             if k in self.handlers:
 200                 h = self.handlers[k]
 201                 if h is None:
 202                     self._entries = tuple()
 203                     return ('', None)
 204                 if h() is False:
 205                     pick = self._entries[self._pick][0]
 206                     self._entries = tuple()
 207                     return (pick, None)
 208             elif len(k) == 1:
 209                 i = self._seek(k, self._pick + 1)
 210                 if i < 0:
 211                     i = self._seek(k, 0)
 212                 if i >= 0:
 213                     self._pick = i
 214 
 215     def _change(self, folder):
 216         if folder == '..':
 217             chdir(folder)
 218             self._current_folder = getcwd()
 219             self._scan()
 220             if len(self._trail) > 0:
 221                 self._pick_name(self._trail[:len(self._trail) - 1], 0)
 222                 self._trail.pop()
 223             return
 224 
 225         if len(self._entries) > 0:
 226             self._trail.extend(self._entries[self._pick][0])
 227         chdir(folder)
 228         self._current_folder = getcwd()
 229         self._scan()
 230         self._pick = 0
 231 
 232     def _fit_string(self, s):
 233         maxlen = max(self._inner_width, 0)
 234         return s if len(s) <= maxlen else s[:maxlen]
 235 
 236     def _pick_name(self, name, fallback = 0):
 237         self._pick = fallback
 238         for i, e in enumerate(self._entries):
 239             if e[0] == name:
 240                 self._pick = i
 241                 return
 242 
 243     def _redraw(self):
 244         title = self._fit_string(self._current_folder)
 245         entries = self._entries
 246         screen = self._screen
 247         iw = self._inner_width
 248         ih = self._inner_height
 249 
 250         if iw < 10 or ih < 10:
 251             return
 252 
 253         screen.erase()
 254 
 255         if title:
 256             screen.addstr(0, 0, f'{self._current_folder:<{iw}}')
 257 
 258         start = self._pick - (self._pick % ih)
 259         stop = start + ih
 260 
 261         at_bottom =  start >= len(entries) - ih
 262         if at_bottom or len(entries) == 0:
 263             msg = f'END ({len(entries):,})'
 264         else:
 265             msg = f'({start + 1:,} / {len(entries):,})'
 266         screen.addstr(0, iw - len(msg), self._fit_string(msg))
 267 
 268         for i, e in enumerate(islice(entries, start, stop)):
 269             try:
 270                 if not e[2]:
 271                     screen.addnstr(i + 1, 0, f'{e[1]:15,}', iw, A_NORMAL)
 272                 style = A_REVERSE if i == self._pick % ih else A_NORMAL
 273                 if e[3]:
 274                     s = f'{e[0]} -> {e[4]}'
 275                     style = style | A_UNDERLINE | A_ITALIC
 276                 else:
 277                     s = e[0]
 278                 indent = 17
 279                 screen.addnstr(i + 1, indent, s, iw - indent, style)
 280             except Exception as e:
 281                 # some utf-8 files have lines which upset func addstr
 282                 screen.addnstr(i + 1, 0, '?' * len(e[0]), iw, style)
 283 
 284         # show up/down arrows
 285         if start > 0:
 286             self._screen.addstr(1, iw - 1, '')
 287         if at_bottom and len(entries) > 0:
 288             self._screen.addstr(ih, iw - 1, '')
 289 
 290         screen.refresh()
 291 
 292     def _scan(self):
 293         def safe_size(e):
 294             try:
 295                 return e.stat().st_size
 296             except Exception:
 297                 return -1
 298 
 299         def f(e):
 300             folder = e.is_dir()
 301             link = e.is_symlink()
 302             size = 0 if folder else safe_size(e)
 303             path = e.path.removeprefix('./')
 304             target = readlink(path) if link else ''
 305             return (path, size, folder, link, target)
 306 
 307         def key(e):
 308             name, _, folder, _, _ = e
 309             return (not folder, name)
 310 
 311         self._entries = sorted((f(e) for e in scandir()), key=key)
 312         if len(self._entries) > 0:
 313             self._max_line_width = max(len(e[0]) for e in self._entries)
 314         else:
 315             self._max_line_width = 0
 316 
 317     def _seek(self, k, start):
 318         if len(k) != 1:
 319             return -1
 320 
 321         k = k.lower()
 322         for i, e in enumerate(islice(self._entries, start, None)):
 323             name = e[0]
 324             if name.startswith(k) or name.lower().startswith(k):
 325                 return start + i
 326         return -1
 327 
 328     def _on_resize(self):
 329         height, width = self._screen.getmaxyx()
 330         self._inner_width = width - 1
 331         self._inner_height = height - 1
 332         self._max_top = max(len(self._entries) - self._inner_height, 0)
 333         ss = self.side_step
 334         self._max_left = self._max_line_width - self._inner_width - 1 + ss
 335         self._max_left = max(self._max_left, 0)
 336         if self._max_left >= self._inner_width - 1 + ss:
 337             self._max_left = 0
 338 
 339     def _on_up(self):
 340         self._pick = max(self._pick - 1, 0)
 341 
 342     def _on_down(self):
 343         limit = max(len(self._entries) - 1, 0)
 344         self._pick = min(self._pick + 1, limit)
 345 
 346     def _on_page_up(self):
 347         self._pick = max(self._pick - self._inner_height, 0)
 348 
 349     def _on_page_down(self):
 350         limit = max(len(self._entries) - 1, 0)
 351         self._pick = min(self._pick + self._inner_height, limit)
 352 
 353     def _on_home(self):
 354         self._pick = 0
 355 
 356     def _on_end(self):
 357         self._pick = max(len(self._entries) - 1, 0)
 358 
 359     def _on_left(self):
 360         try:
 361             self._change('..')
 362         except Exception:
 363             pass
 364 
 365     def _on_right(self):
 366         e = self._entries[self._pick]
 367         if not e[2]:
 368             if self.max_view_size > 0 and e[1] <= self.max_view_size:
 369                 browse_file(join(getcwd(), e[0]), self._screen)
 370         else:
 371             self._change(e[0])
 372 
 373     def _on_refresh(self):
 374         self._scan()
 375 
 376 
 377 class TextViewerTUI:
 378     '''
 379     This is a scrollable viewer for plain-text content. After initializing it
 380     with a TUI screen value, you can configure various fields, before running
 381     it by calling method `run`:
 382         - title, which is shown at the top in reverse-style
 383         - tab_stop, which controls how tabs are turned into spaces
 384         - side_step, which controls the speed of lateral side-scrolling
 385         - handlers, which has all ncurses key-bindings for the viewer
 386     '''
 387 
 388     def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')):
 389         'Optional argument controls which ncurses keys quit the viewer.'
 390 
 391         self.title = ''
 392         self.tab_stop = 4
 393         self.side_step = 1
 394         self.handlers = {
 395             'KEY_RESIZE': lambda: self._on_resize(),
 396             'KEY_UP': lambda: self._on_up(),
 397             'KEY_DOWN': lambda: self._on_down(),
 398             'KEY_NPAGE': lambda: self._on_page_down(),
 399             'KEY_PPAGE': lambda: self._on_page_up(),
 400             'KEY_HOME': lambda: self._on_home(),
 401             'KEY_END': lambda: self._on_end(),
 402             'KEY_LEFT': lambda: self._on_left(),
 403             'KEY_RIGHT': lambda: self._on_right(),
 404         }
 405         if quit_set:
 406             for k in quit_set:
 407                 self.handlers[k] = None
 408 
 409         self._screen = screen
 410         self._inner_width = 0
 411         self._inner_height = 0
 412         self._max_line_width = 0
 413         self._top = 0
 414         self._left = 0
 415         self._max_top = 0
 416         self._max_left = 0
 417         self._lines = tuple()
 418 
 419     def run(self, content):
 420         'Interactively view/browse the string/strings given.'
 421 
 422         if isinstance(content, BaseException):
 423             self._on_resize()
 424             self._show_error(content)
 425             self._screen.getkey()
 426             return
 427 
 428         ts = self.tab_stop
 429         if isinstance(content, str):
 430             self._lines = tuple(l.expandtabs(ts) for l in content.splitlines())
 431         else:
 432             self._lines = tuple(l.expandtabs(ts) for l in content)
 433         content = '' # try to deallocate a few MBs when viewing big files
 434 
 435         if len(self._lines) == 0:
 436             self._max_line_width = 0
 437         else:
 438             self._max_line_width = max(len(l) for l in self._lines)
 439         self._on_resize()
 440 
 441         iw = self._inner_width
 442         ih = self._inner_height
 443 
 444         if iw < 10 or ih < 10:
 445             return
 446 
 447         while True:
 448             self._redraw()
 449             k = self._screen.getkey()
 450             if self.handlers and (k in self.handlers):
 451                 h = self.handlers[k]
 452                 if (h is None) or (h() is False):
 453                     self._lines = tuple()
 454                     return k
 455 
 456     def _fit_string(self, s):
 457         maxlen = max(self._inner_width, 0)
 458         return s if len(s) <= maxlen else s[:maxlen]
 459 
 460     def _redraw(self):
 461         title = self._fit_string(self.title)
 462         lines = self._lines
 463         screen = self._screen
 464         iw = self._inner_width
 465         ih = self._inner_height
 466 
 467         if iw < 10 or ih < 10:
 468             return
 469 
 470         screen.erase()
 471 
 472         if title:
 473             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 474 
 475         at_bottom = len(self._lines) - self._top <= ih
 476         if at_bottom:
 477             msg = f'END ({len(lines):,})' if len(lines) > 0 else '(empty)'
 478         else:
 479             msg = f'({self._top + 1:,} / {len(lines):,})'
 480         screen.addstr(0, iw - len(msg), self._fit_string(msg), A_REVERSE)
 481 
 482         for i, l in enumerate(islice(lines, self._top, self._top + ih)):
 483             if self._left > 0:
 484                 l = l[self._left:]
 485             try:
 486                 screen.addnstr(i + 1, 0, l, iw)
 487             except Exception:
 488                 # some utf-8 files have lines which upset func addstr
 489                 screen.addnstr(i + 1, 0, '?' * len(l), iw)
 490 
 491         # show up/down arrows
 492         if self._top > 0:
 493             self._screen.addstr(1, iw - 1, '')
 494         if self._top < self._max_top:
 495             self._screen.addstr(ih, iw - 1, '')
 496 
 497         screen.refresh()
 498 
 499     def _show_error(self, err):
 500         title = self._fit_string(self.title)
 501         screen = self._screen
 502         iw = self._inner_width
 503         ih = self._inner_height
 504 
 505         if iw < 10 or ih < 10:
 506             return
 507 
 508         screen.erase()
 509         if title:
 510             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 511         screen.addstr(2, 0, self._fit_string(str(err)), A_REVERSE)
 512         screen.refresh()
 513 
 514     def _on_resize(self):
 515         height, width = self._screen.getmaxyx()
 516         self._inner_width = width - 1
 517         self._inner_height = height - 1
 518         self._max_top = max(len(self._lines) - self._inner_height, 0)
 519         ss = self.side_step
 520         self._max_left = self._max_line_width - self._inner_width - 1 + ss
 521         self._max_left = max(self._max_left, 0)
 522 
 523     def _on_up(self):
 524         self._top = max(self._top - 1, 0)
 525 
 526     def _on_down(self):
 527         self._top = min(self._top + 1, self._max_top)
 528 
 529     def _on_page_up(self):
 530         self._top = max(self._top - self._inner_height, 0)
 531 
 532     def _on_page_down(self):
 533         self._top = min(self._top + self._inner_height, self._max_top)
 534 
 535     def _on_home(self):
 536         self._top = 0
 537 
 538     def _on_end(self):
 539         self._top = self._max_top
 540 
 541     def _on_left(self):
 542         self._left = max(self._left - self.side_step, 0)
 543 
 544     def _on_right(self):
 545         self._left = min(self._left + self.side_step, self._max_left)
 546 
 547 
 548 def show_help(screen):
 549     quit_set = ('\x1b', 'KEY_F(1)', 'KEY_F(10)', 'KEY_F(12)', 'KEY_BACKSPACE')
 550     tv = TextViewerTUI(screen, quit_set)
 551     tv.title = 'Help for Browse Folders (bf)'
 552     return tv.run(info)
 553 
 554 
 555 def browse_file(name, screen):
 556     tv = TextViewerTUI(screen)
 557     tv.title = name
 558     tv.side_step = 4
 559     tv.handlers['KEY_F(1)'] = lambda: show_help(screen)
 560     tv.handlers['kLFT5'] = None
 561     tv.handlers['KEY_BACKSPACE'] = None
 562     return tv.run(slurp(name))
 563 
 564 
 565 def run_file_viewer(name):
 566     try:
 567         if name != '-':
 568             tui = SimpleTUI()
 569             tui.start(3, 10)
 570             browse_file(name, tui.screen)
 571             tui.stop()
 572             return 0
 573 
 574         # can read piped input only before entering the `ui-mode`
 575         text = stdin.read()
 576 
 577         # save memory by clearing the variable holding the slurped string
 578         def free_mem(res):
 579             nonlocal text
 580             text = ''
 581             return res
 582 
 583         tui = SimpleTUI()
 584         tui.start(3, 10)
 585         tv = TextViewerTUI(tui.screen)
 586         tv.title = '<stdin>'
 587         tv.side_step = 4
 588         tv.handlers['KEY_F(1)'] = lambda: show_help(tui.screen)
 589         tv.handlers['KEY_BACKSPACE'] = None
 590         tv.run(free_mem(text))
 591         tui.stop()
 592         return 0
 593     except KeyboardInterrupt:
 594         return 1
 595     except Exception as e:
 596         tui.stop()
 597         print(str(e), file=stderr)
 598         return 1
 599 
 600 
 601 def run_folder_browser(name, max_view_size=100*1024**2):
 602     tui = SimpleTUI()
 603     quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')
 604 
 605     try:
 606         tui.start(3, 10)
 607         fb = FolderBrowserTUI(tui.screen, quit_set)
 608         fb.max_view_size = max_view_size
 609         fb.handlers['KEY_F(1)'] = lambda: show_help(tui.screen)
 610         fb.handlers['kRIT5'] = lambda: fb._on_right()
 611         fb.handlers['\t'] = lambda: fb._on_right()
 612         fb.handlers['KEY_BACKSPACE'] = lambda: fb._on_left()
 613         fb.handlers['kLFT5'] = lambda: fb._on_left()
 614         pick, last = fb.run(name)
 615     except KeyboardInterrupt:
 616         tui.stop()
 617         return 1
 618     except Exception as e:
 619         tui.stop()
 620         print(str(e), file=stderr)
 621         return 1
 622 
 623     tui.stop()
 624     dup2(3, 1)
 625 
 626     if last is None or last in quit_set or not pick:
 627         return 1
 628 
 629     print(join(getcwd(), pick))
 630     return 0
 631 
 632 
 633 def show_help(screen):
 634     quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b', 'KEY_F(1)')
 635     h = TextViewerTUI(screen, quit_set)
 636     h.title = 'Help for Browse Folders (bf)'
 637     return h.run(info) != '\x1b'
 638 
 639 
 640 def slurp(name):
 641     try:
 642         with open(name, 'r') as inp:
 643             return inp.read()
 644     except Exception as e:
 645         return e
 646 
 647 
 648 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'):
 649     print(info.strip())
 650     exit(0)
 651 
 652 if len(argv) > 2:
 653     msg = 'there can only be one (optional) starting-folder argument'
 654     print(msg, file=stderr)
 655     exit(4)
 656 
 657 # avoid func curses.wrapper, since it calls func curses.start_color, which in
 658 # turn forces a black background no matter the terminal configuration
 659 
 660 if len(argv) == 2:
 661     run = run_folder_browser if isdir(argv[1]) else run_file_viewer
 662     exit(run(argv[1]))
 663 else:
 664     exit(run_folder_browser('.'))