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