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('.'))