#!/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 math import ceil from os import dup2, getcwd from os.path import abspath from re import compile from sys import argv, exit, stderr, stdin from typing import Callable, Dict, Iterable, 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 Home Go to the first pages End Go to the last pages Up Go to the previous pages Down Go to the next pages Page Up Go to the previous pages Page Down Go to the next pages 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, so they're ignored ansi_re = compile('\x1b\\[([0-9]*[A-HJKST]|[0-9;]*m)') def layout(x: Tuple[str], h: int, i: int = 0) -> Iterable[Tuple[str, str]]: maxw = 0 for k, l in enumerate(x): if k % (2 * h) < h: maxw = max(maxw, len(l)) def index(k: int) -> str: return x[k] if k < len(x) else '' for j in range(h): yield f'{index(i + j):{maxw}}', index(i + j + h) 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(4) def view_text(screen, title: str, text: str, quit_set, help = None) -> None: start = 0 lines = tuple(fix(l) 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 = 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 step = 2 * 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) while True: screen.erase() 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)) for i, (left, right) in enumerate(layout(lines, inner_height, start)): try: line = f'{left} █' if right == '' else f'{left} █ {right}' 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() k = screen.getkey() if k in handlers: handlers[k]() continue if k in quit_set: return def view_help(screen) -> None: title = 'Help for View like a Book (vb)' quit_set = ('\x1b', 'KEY_F(1)', 'KEY_F(10)') view_text(screen, title, info.strip(), quit_set) def enter_ui(): with open('/dev/tty', 'rb') as newin: dup2(newin.fileno(), 0) 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)') 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 = abspath(name) view_text(screen, title, slurp(name), quit_set, help_key) exit_ui(screen, None) 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() 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('-'))