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