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