#!/usr/bin/python3 # The MIT License (MIT) # # Copyright © 2020-2025 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, A_REVERSE from os import dup2 from os.path import abspath from re import compile from sys import argv, exit, stderr, stdin, stdout from typing import Callable, Dict, Tuple info = ''' vb [options...] [filepath/URI...] View (like a) Book is a text user-interface (TUI) to view/read plain-text, laying lines out like facing pages in books. Escape Quit this app; quit help-message screen F1 Toggle help-message screen; the Escape key also quits it F10 Quit this app 0 Show 10 pages/faces per screen 1..9 Show 1..9 pages/faces per screen Home Go to the start End Go to the end Up Go to the previous page Down Go to the next page Page Up Go to the previous page Page Down Go to the next page 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: you can quit the help screen with the Escape key, or with the F1 key. ''' # ansi_re matches ANSI-style sequences ansi_re = compile('\x1b\\[[0-9;]*[A-Za-z]') # tabstop is the max number of spaces tabs are replaced with tabstop = 4 # sep is the separator/joiner of all side-by-side columns sep = ' █ ' def layout(x: Tuple[str], maxw: int, ncols: int, h: int, i: int = 0): def index(k: int) -> str: return x[k] if k < len(x) else '' def pad(s: str) -> str: return f'{s:{maxw + ansi_length(s)}}' for j in range(h): yield tuple(pad(index(i + j + k * h)) for k in range(ncols)) 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 def fix(s: str) -> str: return ansi_re.sub('', s).expandtabs(tabstop) def ansi_length(s: str) -> int: return sum(m.end() - m.start() for m in ansi_re.finditer(s)) def view(scr, title: str, text: str, quit_set, help = None) -> Tuple[int, int]: screen = scr lines = tuple(fix(l) for l in text.splitlines()) text = '' # try to deallocate a few MBs when viewing big files start = 0 maxw = 0 if len(lines) > 0: maxw = max(len(l) - ansi_length(l) for l in lines) h, w = screen.getmaxyx() def find_num_cols(wanted: int, maxw: int, w: int) -> int: n = wanted if (maxw + 3) * n > w: n = int(w / (maxw + 3)) return n if n > 0 else 1 # wanted_ncols = find_num_cols(2, maxw, w) wanted_ncols = 2 # call func on_resize to properly initialize these variables max_start = inner_width = inner_height = step = 0 def on_resize() -> None: nonlocal h, w, max_start, inner_width, inner_height, step h, w = screen.getmaxyx() inner_width = w - 1 inner_height = h - 1 nc = find_num_cols(wanted_ncols, maxw, w) step = nc * inner_height max_start = max(len(lines) - step, 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 - step, 0)), 'KEY_DOWN': lambda: scroll(min(start + step, max_start)), 'KEY_NPAGE': lambda: scroll(min(start + step, max_start)), 'KEY_PPAGE': lambda: scroll(max(start - step, 0)), 'KEY_HOME': lambda: scroll(0), 'KEY_END': lambda: scroll(max_start), } if help: handlers[help] = lambda: view_help(screen) def update() -> None: # screen.erase() screen.clear() screen.addstr(0, 0, shortened(title, inner_width), A_REVERSE) at_bottom = len(lines) - start <= step 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)) ih = inner_height nc = find_num_cols(wanted_ncols, maxw, w) for i, row in enumerate(layout(lines, maxw, nc, ih, start)): try: line = sep.join(row).rstrip() screen.addnstr(i + 1, 0, line, 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() while True: update() k = screen.getkey() if k in handlers: handlers[k]() continue if k.isdigit(): n = int(k) wanted_ncols = n if n != 0 else 10 on_resize() continue if k in quit_set: # screen.erase() # screen.clear() # return wanted_ncols, h return find_num_cols(wanted_ncols, maxw, w), h def view_help(screen) -> None: title = 'Help for View like a Book (vb)' quit_set = ('\x1b', 'KEY_F(1)', 'KEY_F(10)') view(screen, title, info.strip(), quit_set) 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, error_msg) -> None: if screen: resetty() endwin() if error_msg: # stderr is never tampered with print(error_msg, file=stderr) def run(name: str) -> None: screen = None help_key = 'KEY_F(1)' quit_set = ('\x1b', 'KEY_F(10)') piped = not stdout.isatty() try: text = '' # save memory by clearing the variable holding the slurped string def free_mem(res: str) -> str: nonlocal text text = '' return res f = (lambda x: x) if piped else free_mem if name == '-': # can read piped input only before entering the `ui-mode` text = stdin.read() name = '' screen = enter_ui() n, h = view(screen, name, f(text), quit_set, help_key) else: screen = enter_ui() title = abspath(name) text = slurp(name) n, h = view(screen, title, f(text), quit_set, help_key) exit_ui(screen, None) if piped: # func enter_ui kept original stdout as /dev/fd/3 with open('/dev/fd/3', 'w') as out: show_layout(out, text, n, h) return 0 except KeyboardInterrupt: exit_ui(screen, None) return 1 except Exception as e: exit_ui(screen, 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') with open(name) as inp: return inp.read() def show_layout(out, text: str, nc: int, h: int) -> None: maxw = 0 lines = tuple(l.expandtabs(tabstop) for l in text.splitlines()) if len(lines) > 0: maxw = max(len(l) - ansi_length(l) for l in lines) inner_height = h - 1 per_page = nc * inner_height w = maxw * nc + len(sep) * (nc - 1) bottom = '·' * w for start in range(0, len(lines), per_page): for row in layout(lines, maxw, nc, inner_height, start): print(sep.join(row).rstrip(), file=out) print(bottom, file=out) out.flush() if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'): print(info.strip()) 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 exit(run(argv[1] if len(argv) == 2 else '-'))