File: bf.py
   1 #!/usr/bin/python3
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright © 2020-2025 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 cbreak, curs_set, endwin, initscr, noecho, resetty, savetty
  27 from curses import set_escdelay
  28 from curses import A_NORMAL, A_REVERSE
  29 from itertools import islice
  30 from os import chdir, dup2, getcwd, scandir, DirEntry
  31 from os.path import join, isdir
  32 from sys import argv, exit, stderr, stdin, stdout
  33 from typing import Any, Callable, Dict, List, Tuple, Union
  34 
  35 
  36 info = '''
  37 bf [options...] [file/folder...]
  38 
  39 
  40 Browse Folders is a text user-interface (TUI) to do just that. By default
  41 it starts browsing from the current folder, but you can choose a different
  42 starting point as an optional cmd-line argument when starting this script.
  43 
  44 It's also a (UTF-8) plain-text file viewer; when the optional command-line
  45 argument is a filename (instead of a starting folder) it acts purely as a
  46 viewer for the file given.
  47 
  48 
  49     Enter      Quit this app, emitting the currently-selected entry
  50     Escape     Quit this app without emitting an entry; quit text viewers
  51     F1         Toggle help-message screen; the Escape key also quits it
  52     F5         Update current-folder entries, in case they've changed
  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 Letters and digits also let you jump/move among entries case-insensitively
  82 starting with the key pressed.
  83 
  84 The right side of the screen also shows little up/down arrow symbols when
  85 there are more entries before/after the ones currently showing.
  86 
  87 The only options are one of the help options, which show this message:
  88 these are `-h`, `--h`, `-help`, and `--help`, without the quotes.
  89 
  90 You can also view this help message by pressing the F1 key while browsing
  91 folders: the help viewer works the same as the text-file viewer; you can
  92 quit the help screen with the Escape key, or with the F1 key.
  93 
  94 When things have changed in the current folder, you can press the F5 key
  95 to reload the entries on screen, so there's no need to manually get out
  96 and back into the current folder as a workaround.
  97 '''
  98 
  99 help_key = 'KEY_F(1)'
 100 quit_help = ('\x1b', help_key, 'KEY_F(10)', 'KEY_F(12)', 'KEY_BACKSPACE')
 101 quit_viewer = ('\x1b', 'KEY_LEFT', 'KEY_F(10)', 'KEY_F(12)', 'KEY_BACKSPACE')
 102 
 103 
 104 def shortened(s: str, maxlen: int, trail: str = '') -> str:
 105     maxlen = max(maxlen, 0)
 106     return s if len(s) <= maxlen else s[:maxlen - len(trail)] + trail
 107 
 108 
 109 class UI:
 110     entries: List[Tuple[str, int, bool]]
 111     selections: List[int]
 112 
 113     def __init__(self, scr) -> None:
 114         self.screen = scr
 115         self.entries = find_entries()
 116         self.selections = [0]
 117 
 118     def select(self, i: int, fallback: int = 0) -> None:
 119         i = min(i, len(self.entries) - 1)
 120         i = max(i, 0)
 121         if len(self.selections) > 0:
 122             self.selections[-1] = i
 123         else:
 124             self.selections.append(fallback)
 125 
 126     def update_folder_area(self) -> None:
 127         scr = self.screen
 128         h, w = scr.getmaxyx()
 129         inner_height = h - 1
 130 
 131         num_spaces = 2
 132         max_digits = 16
 133         max_len = max(w - max_digits - num_spaces, 0)
 134         sel = self.selections[-1] if len(self.selections) > 0 else 0
 135         start = int(sel / inner_height) * inner_height
 136         stop = max(start + inner_height, start)
 137 
 138         scr.erase()
 139 
 140         for i, e in enumerate(islice(self.entries, start, stop)):
 141             name, size, folder = e
 142 
 143             # △ ▽ ▴ ▾ ▵ ▿
 144             try:
 145                 if i == 0 and start > 0:
 146                     scr.addstr(1, w - 1, '')
 147                 if i == inner_height - 1 and len(self.entries) > stop:
 148                     scr.addstr(inner_height, w - 1, '')
 149             except:
 150                 pass
 151 
 152             if not folder:
 153                 if size < 0:
 154                     msg = '?'
 155                     scr.addstr(i + 1, 0, f'{msg:>{max_digits}}')
 156                 else:
 157                     scr.addstr(i + 1, 0, f'{size:{max_digits},}')
 158 
 159             short_name = shortened(name, max_len, '')
 160             style = A_NORMAL if i != sel - start else A_REVERSE
 161             scr.addstr(i + 1, max_digits + num_spaces, short_name, style)
 162 
 163         n = len(self.entries)
 164         msg = f'({sel + 1:,} / {n:,})' if n > 0 else '(no files)'
 165         scr.addstr(0, w - 1 - len(msg), msg)
 166         scr.addstr(0, 0, getcwd())
 167         scr.refresh()
 168 
 169 
 170 def seek(items: List[Tuple[str, int, bool]], prefix: str, start: int) -> int:
 171     for i, e in enumerate(islice(items, start, None)):
 172         name = e[0]
 173         if name.startswith(prefix) or name.lower().startswith(prefix):
 174             return start + i
 175     return -1
 176 
 177 
 178 def find_entries() -> List[Tuple[str, int, bool]]:
 179     def safe_size(e: DirEntry) -> int:
 180         try:
 181             return e.stat().st_size
 182         except Exception:
 183             return -1
 184 
 185     def f(e: DirEntry) -> Tuple[str, int, bool]:
 186         path = e.path.removeprefix('./')
 187         return (path, 0, True) if e.is_dir() else (path, safe_size(e), False)
 188 
 189     def key(e: Tuple[str, int, bool]) -> Any:
 190         name, _, folder = e
 191         return (not folder, name)
 192 
 193     return sorted((f(e) for e in scandir()), key=key)
 194 
 195 
 196 def view_text(screen, title: str, text: str, quit_set, help = None) -> None:
 197     start = 0
 198 
 199     lines = tuple(l.expandtabs(4) for l in text.splitlines())
 200     text = '' # try to deallocate a few MBs when viewing big files
 201     # call func on_resize to properly initialize these variables
 202     h = w = max_start = inner_width = inner_height = 0
 203 
 204     def on_resize() -> None:
 205         nonlocal h, w, max_start, inner_width, inner_height
 206         h, w = screen.getmaxyx()
 207         inner_width = w - 1
 208         inner_height = h - 1
 209         max_start = max(len(lines) - inner_height, 0)
 210 
 211     def scroll(to: int) -> None:
 212         nonlocal start
 213         start = to
 214 
 215     # initialize local variables
 216     on_resize()
 217 
 218     handlers: Dict[str, Callable] = {
 219         'KEY_RESIZE': on_resize,
 220         'KEY_UP': lambda: scroll(max(start - 1, 0)),
 221         'KEY_DOWN': lambda: scroll(min(start + 1, max_start)),
 222         'KEY_NPAGE': lambda: scroll(min(start + inner_height, max_start)),
 223         'KEY_PPAGE': lambda: scroll(max(start - inner_height, 0)),
 224         'KEY_HOME': lambda: scroll(0),
 225         'KEY_END': lambda: scroll(max_start),
 226     }
 227 
 228     if help:
 229         handlers[help] = lambda: view_help(screen)
 230 
 231     while True:
 232         screen.erase()
 233 
 234         screen.addstr(0, 0, shortened(title, inner_width), A_REVERSE)
 235         at_bottom = len(lines) - start <= inner_height
 236         if at_bottom:
 237             msg = f'END ({len(lines):,})' if len(lines) > 0 else '(empty)'
 238         else:
 239             msg = f'({start + 1:,} / {len(lines):,})'
 240         screen.addstr(0, w - 1 - len(msg), shortened(msg, inner_width))
 241 
 242         subset = islice(lines, start, start + inner_height)
 243         for i, l in enumerate(subset):
 244             try:
 245                 screen.addnstr(i + 1, 0, l, inner_width)
 246             except Exception:
 247                 # some utf-8 files have lines which upset func addstr
 248                 pass
 249 
 250         # add up/down scrolling arrows
 251         try:
 252             if start > 0:
 253                 screen.addstr(1, w - 1, '')
 254             if start < max_start:
 255                 screen.addstr(h - 1, w - 1, '')
 256         except:
 257             pass
 258 
 259         screen.refresh()
 260 
 261         k = screen.getkey()
 262         if k in handlers:
 263             handlers[k]()
 264             continue
 265         if k == '\n':
 266             return True
 267         if k in quit_set:
 268             return False
 269 
 270 
 271 def help(ui: UI, _: int) -> None:
 272     view_help(ui.screen)
 273 
 274 
 275 def view_help(screen) -> None:
 276     view_text(screen, 'Help for Browse Folders (bf)', info.strip(), quit_help)
 277 
 278 
 279 def refresh(ui: UI, _: int) -> None:
 280     if len(ui.selections) > 0:
 281         sel = ui.entries[ui.selections[-1]]
 282         ui.entries = find_entries()
 283         i = ui.entries.index(sel)
 284         ui.selections[-1] = i if i >= 0 else 0
 285     else:
 286         ui.entries = find_entries()
 287         ui.selections.append(0)
 288 
 289 
 290 def dig(ui: UI, _: int) -> None:
 291     if len(ui.selections) == 0:
 292         return
 293 
 294     name, _, folder = ui.entries[ui.selections[-1]]
 295 
 296     if folder:
 297         try:
 298             chdir(name)
 299             ui.entries = find_entries()
 300             ui.selections.append(0)
 301         except Exception:
 302             pass
 303     else:
 304         title = join(getcwd(), name)
 305         try:
 306             scr = ui.screen
 307             v = view_text(scr, title, slurp_file(name), quit_viewer, help_key)
 308             if isinstance(v, bool):
 309                 return v
 310         except UnicodeDecodeError:
 311             pass
 312 
 313 
 314 def back_out(ui: UI, _: int) -> None:
 315     try:
 316         chdir('..')
 317         ui.entries = find_entries()
 318         if len(ui.selections) > 0:
 319             ui.selections = ui.selections[:-1]
 320     except Exception:
 321         pass
 322 
 323 
 324 def select_next(ui: UI, _: int) -> None:
 325     if len(ui.selections) > 0:
 326         ui.selections[-1] += 1
 327         ui.selections[-1] %= max(len(ui.entries), 1)
 328     else:
 329         ui.selections.append(0)
 330 
 331 
 332 def select_previous(ui: UI, _: int) -> None:
 333     if len(ui.selections) > 0:
 334         ui.selections[-1] -= 1
 335         ui.selections[-1] %= max(len(ui.entries), 1)
 336     else:
 337         ui.selections.append(len(ui.entries) - 1)
 338 
 339 
 340 def next_page(ui: UI, height: int) -> None:
 341     if len(ui.selections) > 0:
 342         ui.select(min(ui.selections[-1] + height, len(ui.entries) - 1))
 343     else:
 344         ui.select(0)
 345 
 346 
 347 def previous_page(ui: UI, height: int) -> None:
 348     if len(ui.selections) > 0:
 349         ui.select(max(ui.selections[-1] - height, 0))
 350     else:
 351         ui.select(len(ui.entries) - 1)
 352 
 353 
 354 def go_start(ui: UI, _: int) -> None:
 355     ui.select(0, 0)
 356 
 357 
 358 def go_end(ui: UI, _: int) -> None:
 359     i = len(ui.entries) - 1
 360     ui.select(i, i)
 361 
 362 
 363 key_handlers: Dict[str, Callable] = {
 364     help_key: help,
 365     'KEY_F(5)': refresh,
 366     'KEY_RIGHT': dig,
 367     '\t': dig,
 368     ' ': dig,
 369     'KEY_LEFT': back_out,
 370     'KEY_BACKSPACE': back_out,
 371     'KEY_DOWN': select_next,
 372     'KEY_UP': select_previous,
 373     'KEY_NPAGE': next_page,
 374     'KEY_PPAGE': previous_page,
 375     'KEY_HOME': go_start,
 376     'KEY_END': go_end,
 377 }
 378 
 379 
 380 def loop(ui: UI) -> Tuple[Union[str, None], int]:
 381     while True:
 382         ui.update_folder_area()
 383 
 384         try:
 385             key = ui.screen.getkey()
 386         except KeyboardInterrupt:
 387             return (None, 1)
 388 
 389         if key in ('\x1b', 'KEY_F(10)', 'KEY_F(12)'):
 390             return (None, 1)
 391 
 392         if key == '\n':
 393             if len(ui.selections) > 0:
 394                 name = ui.entries[ui.selections[-1]][0]
 395                 return (join(getcwd(), name), 0)
 396 
 397         if len(key) == 1:
 398             low = key.lower()
 399             start = ui.selections[-1] + 1 if len(ui.selections) > 0 else 0
 400             i = seek(ui.entries, low, start)
 401             if i < 0:
 402                 i = seek(ui.entries, low, 0)
 403             else:
 404                 ui.select(i, i)
 405             continue
 406 
 407         if key in key_handlers:
 408             h, _ = ui.screen.getmaxyx()
 409             v = key_handlers[key](ui, h - 1)
 410             if v is True:
 411                 if len(ui.selections) > 0:
 412                     name = ui.entries[ui.selections[-1]][0]
 413                     return (join(getcwd(), name), 0)
 414             if v is False:
 415                 return (None, 1)
 416 
 417 
 418 def enter_ui():
 419     # keep original stdout as /dev/fd/3
 420     dup2(1, 3)
 421     # separate live output from final (optional) result on stdout
 422     with open('/dev/tty', 'rb') as newin, open('/dev/tty', 'wb') as newout:
 423         dup2(newin.fileno(), 0)
 424         dup2(newout.fileno(), 1)
 425 
 426     screen = initscr()
 427     savetty()
 428     noecho()
 429     cbreak()
 430     screen.keypad(True)
 431     curs_set(0)
 432     set_escdelay(10)
 433     return screen
 434 
 435 
 436 def exit_ui(screen, result, error_msg) -> None:
 437     if screen:
 438         resetty()
 439         endwin()
 440 
 441     if result:
 442         # func enter_ui kept original stdout as /dev/fd/3
 443         with open('/dev/fd/3', 'w') as out:
 444             print(result, file=out)
 445 
 446     if error_msg:
 447         # stderr is never tampered with
 448         print(error_msg, file=stderr)
 449 
 450 
 451 def run_folder_browser(name: str) -> int:
 452     screen = None
 453     try:
 454         if name != '' and name != '.':
 455             chdir(name)
 456         screen = enter_ui()
 457         result, exit_code = loop(UI(screen))
 458         exit_ui(screen, result, None)
 459         return exit_code
 460     except KeyboardInterrupt:
 461         exit_ui(screen, None, None)
 462         return 1
 463     except Exception as e:
 464         exit_ui(screen, None, f'\x1b[31m{e}\x1b[0m')
 465         return 1
 466 
 467 
 468 def run_file_viewer(name: str) -> None:
 469     screen = None
 470 
 471     try:
 472         if name == '-':
 473             # can read piped input only before entering the `ui-mode`
 474             text = stdin.read()
 475 
 476             # save memory by clearing the variable holding the slurped string
 477             def free_mem(res: str) -> str:
 478                 nonlocal text
 479                 text = ''
 480                 return res
 481 
 482             screen = enter_ui()
 483             title = '<stdin>'
 484             view_text(screen, title, free_mem(text), quit_viewer, help_key)
 485         else:
 486             screen = enter_ui()
 487             title = join(getcwd(), name)
 488             view_text(screen, title, slurp(name), quit_viewer, help_key)
 489 
 490         exit_ui(screen, None, None)
 491         return 0
 492     except KeyboardInterrupt:
 493         exit_ui(screen, None, None)
 494         return 1
 495     except Exception as e:
 496         exit_ui(screen, None, f'\x1b[31m{e}\x1b[0m')
 497         return 1
 498 
 499 
 500 def slurp(name: str) -> str:
 501     protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:')
 502     seems_url = any(name.startswith(p) for p in protocols)
 503 
 504     if seems_url:
 505         from urllib.request import urlopen
 506         with urlopen(name) as inp:
 507             return inp.read().decode('utf-8')
 508 
 509     return slurp_file(name)
 510 
 511 
 512 def slurp_file(name: str) -> str:
 513     with open(name) as inp:
 514         return inp.read()
 515 
 516 
 517 def run(name: str) -> int:
 518     f = run_folder_browser if isdir(name) else run_file_viewer
 519     return f(name)
 520 
 521 
 522 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'):
 523     print(info.strip(), file=stdout)
 524     exit(0)
 525 
 526 if len(argv) > 2:
 527     print(info.strip(), file=stderr)
 528     msg = 'there can only be one (optional) starting-folder argument'
 529     print(f'\x1b[31m{msg}\x1b[0m', file=stderr)
 530     exit(4)
 531 
 532 # avoid func curses.wrapper, since it calls func curses.start_color, which in
 533 # turn forces a black background even on terminals configured to use any other
 534 # background color
 535 
 536 if len(argv) == 2:
 537     exit(run(argv[1]))
 538 else:
 539     exit(run_folder_browser('.'))