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