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