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