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