#!/usr/bin/python3 # The MIT License (MIT) # # Copyright © 2024 pacman64 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the “Software”), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. from curses import \ cbreak, curs_set, endwin, initscr, noecho, resetty, savetty, set_escdelay from curses import A_NORMAL, A_REVERSE from itertools import islice from os import chdir, dup2, getcwd, scandir, DirEntry from os.path import join, isdir from sys import argv, exit, stderr, stdin from typing import Any, Callable, Dict, List, Tuple, Union info = ''' bf [options...] [file/folder...] Browse Folders is a text user-interface (TUI) to do just that. By default it starts browsing from the current folder, but you can choose a different starting point as an optional cmd-line argument when starting this script. It's also a (UTF-8) plain-text file viewer; when the optional command-line argument is a filename (instead of a starting folder) it acts purely as a viewer for the file given. Enter Quit this app, emitting the currently-selected entry Escape Quit this app without emitting an entry; quit text viewers F1 Toggle help-message screen; the Escape key also quits it F5 Update current-folder entries, in case they've changed F10 Quit this app without emitting an entry; quit text viewers Left Go to the current folder's parent folder Right Go to the currently-selected folder Backspace Go to the current folder's parent folder Tab Go to the currently-selected folder Home Select the first entry in the current folder End Select the last entry in the current folder Up Select the entry before the currently selected one Down Select the entry after the currently selected one Page Up Select entry by jumping one screen backward Page Down Select entry by jumping one screen forward [Other] Jump to the first/next entry whose name starts with that letter or digit; letters are matched case-insensitively Escape quits the app without emitting the currently-selected item and with an error-code, while Enter emits the selected item, quitting successfully. Folders are shown without a file-size, and are always shown before files. Some file/folder entries may be special and/or give an error when queried for their file-size: these are shown with a question mark where their size would normally be. Letters and digits also let you jump/move among entries case-insensitively starting with the key pressed. The right side of the screen also shows little up/down arrow symbols when there are more entries before/after the ones currently showing. The only options are one of the help options, which show this message: these are `-h`, `--h`, `-help`, and `--help`, without the quotes. You can also view this help message by pressing the F1 key while browsing folders: the help viewer works the same as the text-file viewer; you can quit the help screen with the Escape key, or with the F1 key. When things have changed in the current folder, you can press the F5 key to reload the entries on screen, so there's no need to manually get out and back into the current folder as a workaround. ''' def shortened(s: str, maxlen: int, trail: str = '') -> str: maxlen = max(maxlen, 0) return s if len(s) <= maxlen else s[:maxlen - len(trail)] + trail class UI: entries: List[Tuple[str, int, bool]] selections: List[int] def __init__(self, scr) -> None: self.screen = scr self.entries = find_entries() self.selections = [0] def select(self, i: int, fallback: int = 0) -> None: i = min(i, len(self.entries) - 1) i = max(i, 0) if len(self.selections) > 0: self.selections[-1] = i else: self.selections.append(fallback) def update_folder_area(self) -> None: scr = self.screen h, w = scr.getmaxyx() inner_height = h - 1 num_spaces = 2 max_digits = 16 max_len = max(w - max_digits - num_spaces, 0) sel = self.selections[-1] if len(self.selections) > 0 else 0 start = int(sel / inner_height) * inner_height stop = max(start + inner_height, start) scr.erase() for i, e in enumerate(islice(self.entries, start, stop)): name, size, folder = e # △ ▽ ▴ ▾ ▵ ▿ try: if i == 0 and start > 0: scr.addstr(1, w - 1, '▲') if i == inner_height - 1 and len(self.entries) > stop: scr.addstr(inner_height, w - 1, '▼') except: pass if not folder: if size < 0: msg = '?' scr.addstr(i + 1, 0, f'{msg:>{max_digits}}') else: scr.addstr(i + 1, 0, f'{size:{max_digits},}') short_name = shortened(name, max_len, '…') style = A_NORMAL if i != sel - start else A_REVERSE scr.addstr(i + 1, max_digits + num_spaces, short_name, style) n = len(self.entries) msg = f'({sel + 1:,} / {n:,})' if n > 0 else '(no files)' scr.addstr(0, w - 1 - len(msg), msg) scr.addstr(0, 0, getcwd(), A_REVERSE) scr.refresh() def seek(items: List[Tuple[str, int, bool]], prefix: str, start: int) -> int: for i, e in enumerate(islice(items, start, None)): name = e[0] if name.startswith(prefix) or name.lower().startswith(prefix): return start + i return -1 def find_entries() -> List[Tuple[str, int, bool]]: def safe_size(e: DirEntry) -> int: try: return e.stat().st_size except Exception: return -1 def f(e: DirEntry) -> Tuple[str, int, bool]: path = e.path.removeprefix('./') return (path, 0, True) if e.is_dir() else (path, safe_size(e), False) def key(e: Tuple[str, int, bool]) -> Any: name, _, folder = e return (not folder, name) return sorted((f(e) for e in scandir()), key=key) def view_text(screen, title: str, text: str, quit_set, help = None) -> None: start = 0 lines = tuple(l.expandtabs(4) for l in text.splitlines()) text = '' # may immediately dealloc a few MBs when viewing big files # call func on_resize to properly initialize these variables h = w = max_start = inner_width = inner_height = 0 def on_resize() -> None: nonlocal h, w, max_start, inner_width, inner_height h, w = screen.getmaxyx() inner_width = w - 1 inner_height = h - 1 max_start = max(len(lines) - inner_height, 0) def scroll(to: int) -> None: nonlocal start start = to # initialize local variables on_resize() handlers: Dict[str, Callable] = { 'KEY_RESIZE': on_resize, 'KEY_UP': lambda: scroll(max(start - 1, 0)), 'KEY_DOWN': lambda: scroll(min(start + 1, max_start)), 'KEY_NPAGE': lambda: scroll(min(start + inner_height, max_start)), 'KEY_PPAGE': lambda: scroll(max(start - inner_height, 0)), 'KEY_HOME': lambda: scroll(0), 'KEY_END': lambda: scroll(max_start), } if help: handlers[help] = lambda: view_help(screen) while True: screen.erase() screen.addstr(0, 0, shortened(title, inner_width), A_REVERSE) at_bottom = len(lines) - start <= inner_height if at_bottom: msg = f'END ({len(lines):,})' if len(lines) > 0 else '(empty)' else: msg = f'({start + 1:,} / {len(lines):,})' screen.addstr(0, w - 1 - len(msg), shortened(msg, inner_width)) subset = islice(lines, start, start + inner_height) for i, l in enumerate(subset): try: screen.addnstr(i + 1, 0, l, inner_width) except Exception: # some utf-8 files have lines which upset func addstr pass # add up/down scrolling arrows try: if start > 0: screen.addstr(1, w - 1, '▲') if start < max_start: screen.addstr(h - 1, w - 1, '▼') except: pass screen.refresh() k = screen.getkey() if k in handlers: handlers[k]() continue if k in quit_set: return def help(ui: UI, _: int) -> None: view_help(ui.screen) def view_help(screen) -> None: quit_set = ('\x1b', 'KEY_F(1)', 'KEY_F(10)', 'KEY_BACKSPACE') view_text(screen, 'Help for Browse Folders (bf)', info.strip(), quit_set) def refresh(ui: UI, _: int) -> None: if len(ui.selections) > 0: sel = ui.entries[ui.selections[-1]] ui.entries = find_entries() i = ui.entries.index(sel) ui.selections[-1] = i if i >= 0 else 0 else: ui.entries = find_entries() ui.selections.append(0) def dig(ui: UI, _: int) -> None: if len(ui.selections) == 0: return name, _, folder = ui.entries[ui.selections[-1]] if folder: try: chdir(name) ui.entries = find_entries() ui.selections.append(0) except Exception: pass else: title = join(getcwd(), name) quit_set = ('\x1b', 'KEY_LEFT', 'KEY_F(10)', 'KEY_BACKSPACE') try: view_text(ui.screen, title, slurp_file(name), quit_set, 'KEY_F(1)') except UnicodeDecodeError: pass def back_out(ui: UI, _: int) -> None: try: chdir('..') ui.entries = find_entries() if len(ui.selections) > 0: ui.selections = ui.selections[:-1] except Exception: pass def select_next(ui: UI, _: int) -> None: if len(ui.selections) > 0: ui.selections[-1] += 1 ui.selections[-1] %= max(len(ui.entries), 1) else: ui.selections.append(0) def select_previous(ui: UI, _: int) -> None: if len(ui.selections) > 0: ui.selections[-1] -= 1 ui.selections[-1] %= max(len(ui.entries), 1) else: ui.selections.append(len(ui.entries) - 1) def next_page(ui: UI, height: int) -> None: if len(ui.selections) > 0: ui.select(min(ui.selections[-1] + height, len(ui.entries) - 1)) else: ui.select(0) def previous_page(ui: UI, height: int) -> None: if len(ui.selections) > 0: ui.select(max(ui.selections[-1] - height, 0)) else: ui.select(len(ui.entries) - 1) def go_start(ui: UI, _: int) -> None: ui.select(0, 0) def go_end(ui: UI, _: int) -> None: i = len(ui.entries) - 1 ui.select(i, i) key_handlers: Dict[str, Callable] = { 'KEY_F(1)': help, 'KEY_F(5)': refresh, 'KEY_RIGHT': dig, '\t': dig, ' ': dig, 'KEY_LEFT': back_out, 'KEY_BACKSPACE': back_out, 'KEY_DOWN': select_next, 'KEY_UP': select_previous, 'KEY_NPAGE': next_page, 'KEY_PPAGE': previous_page, 'KEY_HOME': go_start, 'KEY_END': go_end, } def loop(ui: UI) -> Tuple[Union[str, None], int]: while True: ui.update_folder_area() try: key = ui.screen.getkey() except KeyboardInterrupt: return (None, 1) if key in ('\x1b', 'KEY_F(10)'): return (None, 1) if key == '\n': if len(ui.selections) > 0: name = ui.entries[ui.selections[-1]][0] return (join(getcwd(), name), 0) if len(key) == 1: low = key.lower() start = ui.selections[-1] + 1 if len(ui.selections) > 0 else 0 i = seek(ui.entries, low, start) if i < 0: i = seek(ui.entries, low, 0) if i >= 0: ui.select(i, i) continue if key in key_handlers: h, _ = ui.screen.getmaxyx() key_handlers[key](ui, h - 1) def enter_ui(): # keep original stdout as /dev/fd/3 dup2(1, 3) # separate live output from final (optional) result on stdout with open('/dev/tty', 'rb') as newin, open('/dev/tty', 'wb') as newout: dup2(newin.fileno(), 0) dup2(newout.fileno(), 1) screen = initscr() savetty() noecho() cbreak() screen.keypad(True) curs_set(0) set_escdelay(10) return screen def exit_ui(screen, result, error_msg) -> None: if screen: resetty() endwin() if result: # func enter_ui keeps original stdout as /dev/fd/3 with open('/dev/fd/3', 'w') as out: print(result, file=out) if error_msg: # stderr is never tampered with print(error_msg, file=stderr) def run_folder_browser(name: str) -> int: screen = None try: if name != '' and name != '.': chdir(name) screen = enter_ui() result, exit_code = loop(UI(screen)) exit_ui(screen, result, None) return exit_code except KeyboardInterrupt: exit_ui(screen, None, None) return 1 except Exception as e: exit_ui(screen, None, f'\x1b[31m{e}\x1b[0m') return 1 def run_file_viewer(name: str) -> None: screen = None help_key = 'KEY_F(1)' quit_set = ('\x1b', 'KEY_LEFT', 'KEY_F(10)', 'KEY_BACKSPACE') try: if name == '-': # can read piped input only before entering the `ui-mode` text = stdin.read() # save memory by clearing the variable holding the slurped string def free_mem(res: str) -> str: nonlocal text text = '' return res screen = enter_ui() view_text(screen, '', free_mem(text), quit_set, help_key) else: screen = enter_ui() title = join(getcwd(), name) view_text(screen, title, slurp(name), quit_set, help_key) exit_ui(screen, None, None) return 0 except KeyboardInterrupt: exit_ui(screen, None, None) return 1 except Exception as e: exit_ui(screen, None, f'\x1b[31m{e}\x1b[0m') return 1 def slurp(name: str) -> str: protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:') seems_url = any(name.startswith(p) for p in protocols) if seems_url: from urllib.request import urlopen with urlopen(name) as inp: return inp.read().decode('utf-8') return slurp_file(name) def slurp_file(name: str) -> str: with open(name) as inp: return inp.read() def run(name: str) -> int: f = run_folder_browser if isdir(name) else run_file_viewer return f(name) if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'): print(info.strip(), file=stderr) exit(0) if len(argv) > 2: print(info.strip(), file=stderr) msg = 'there can only be one (optional) starting-folder argument' print(f'\x1b[31m{msg}\x1b[0m', file=stderr) exit(4) # avoid func curses.wrapper, since it calls func curses.start_color, which in # turn forces a black background even on terminals configured to use any other # background color if len(argv) == 2: exit(run(argv[1])) else: exit(run_folder_browser('.'))