File: vb.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 math import ceil 30 from os import dup2, getcwd 31 from os.path import abspath 32 from re import compile 33 from sys import argv, exit, stderr, stdin 34 from typing import Callable, Dict, Iterable, Tuple 35 36 37 info = ''' 38 vb [options...] [filepath/URI...] 39 40 41 View (like a) Book is a text user-interface (TUI) to view/read plain-text, 42 laying lines out like facing pages in books. 43 44 45 Escape Quit this app; quit help-message screen 46 F1 Toggle help-message screen; the Escape key also quits it 47 F10 Quit this app 48 49 Home Go to the first pages 50 End Go to the last pages 51 Up Go to the previous pages 52 Down Go to the next pages 53 Page Up Go to the previous pages 54 Page Down Go to the next pages 55 56 57 The right side of the screen also shows little up/down arrow symbols when 58 there are more entries before/after the ones currently showing. 59 60 The only options are one of the help options, which show this message: 61 these are `-h`, `--h`, `-help`, and `--help`, without the quotes. 62 63 You can also view this help message by pressing the F1 key: you can quit 64 the help screen with the Escape key, or with the F1 key. 65 ''' 66 67 # ansi_re matches ANSI-style sequences, so they're ignored 68 ansi_re = compile('\x1b\\[([0-9]*[A-HJKST]|[0-9;]*m)') 69 70 71 def layout(x: Tuple[str], h: int, i: int = 0) -> Iterable[Tuple[str, str]]: 72 maxw = 0 73 for k, l in enumerate(x): 74 if k % (2 * h) < h: 75 maxw = max(maxw, len(l)) 76 77 def index(k: int) -> str: 78 return x[k] if k < len(x) else '' 79 80 for j in range(h): 81 yield f'{index(i + j):{maxw}}', index(i + j + h) 82 83 84 def shortened(s: str, maxlen: int, trail: str = '') -> str: 85 maxlen = max(maxlen, 0) 86 return s if len(s) <= maxlen else s[:maxlen - len(trail)] + trail 87 88 89 def fix(s: str) -> str: 90 return ansi_re.sub('', s).expandtabs(4) 91 92 93 def view_text(screen, title: str, text: str, quit_set, help = None) -> None: 94 start = 0 95 96 lines = tuple(fix(l) for l in text.splitlines()) 97 text = '' # may immediately dealloc a few MBs when viewing big files 98 99 # call func on_resize to properly initialize these variables 100 h = w = max_start = inner_width = inner_height = step = 0 101 102 def on_resize() -> None: 103 nonlocal h, w, max_start, inner_width, inner_height, step 104 h, w = screen.getmaxyx() 105 inner_width = w - 1 106 inner_height = h - 1 107 step = 2 * inner_height 108 max_start = max(len(lines) - step, 0) 109 110 def scroll(to: int) -> None: 111 nonlocal start 112 start = to 113 114 # initialize local variables 115 on_resize() 116 117 handlers: Dict[str, Callable] = { 118 'KEY_RESIZE': on_resize, 119 'KEY_UP': lambda: scroll(max(start - step, 0)), 120 'KEY_DOWN': lambda: scroll(min(start + step, max_start)), 121 'KEY_NPAGE': lambda: scroll(min(start + step, max_start)), 122 'KEY_PPAGE': lambda: scroll(max(start - step, 0)), 123 'KEY_HOME': lambda: scroll(0), 124 'KEY_END': lambda: scroll(max_start), 125 } 126 127 if help: 128 handlers[help] = lambda: view_help(screen) 129 130 while True: 131 screen.erase() 132 133 screen.addstr(0, 0, shortened(title, inner_width), A_REVERSE) 134 at_bottom = len(lines) - start <= step 135 if at_bottom: 136 msg = f'END ({len(lines):,})' if len(lines) > 0 else '(empty)' 137 else: 138 msg = f'({start + 1:,} / {len(lines):,})' 139 screen.addstr(0, w - 1 - len(msg), shortened(msg, inner_width)) 140 141 for i, (left, right) in enumerate(layout(lines, inner_height, start)): 142 try: 143 line = f'{left} █' if right == '' else f'{left} █ {right}' 144 screen.addnstr(i + 1, 0, line, inner_width) 145 except Exception: 146 # some utf-8 files have lines which upset func addstr 147 pass 148 149 # add up/down scrolling arrows 150 try: 151 if start > 0: 152 screen.addstr(1, w - 1, '▲') 153 if start < max_start: 154 screen.addstr(h - 1, w - 1, '▼') 155 except: 156 pass 157 158 screen.refresh() 159 160 k = screen.getkey() 161 if k in handlers: 162 handlers[k]() 163 continue 164 if k in quit_set: 165 return 166 167 168 def view_help(screen) -> None: 169 title = 'Help for View like a Book (vb)' 170 quit_set = ('\x1b', 'KEY_F(1)', 'KEY_F(10)') 171 view_text(screen, title, info.strip(), quit_set) 172 173 174 def enter_ui(): 175 with open('/dev/tty', 'rb') as newin: 176 dup2(newin.fileno(), 0) 177 178 screen = initscr() 179 savetty() 180 noecho() 181 cbreak() 182 screen.keypad(True) 183 curs_set(0) 184 set_escdelay(10) 185 return screen 186 187 188 def exit_ui(screen, error_msg) -> None: 189 if screen: 190 resetty() 191 endwin() 192 193 if error_msg: 194 # stderr is never tampered with 195 print(error_msg, file=stderr) 196 197 198 def run(name: str) -> None: 199 screen = None 200 help_key = 'KEY_F(1)' 201 quit_set = ('\x1b', 'KEY_F(10)') 202 203 try: 204 if name == '-': 205 # can read piped input only before entering the `ui-mode` 206 text = stdin.read() 207 # save memory by clearing the variable holding the slurped string 208 def free_mem(res: str) -> str: 209 nonlocal text 210 text = '' 211 return res 212 screen = enter_ui() 213 view_text(screen, '<stdin>', free_mem(text), quit_set, help_key) 214 else: 215 screen = enter_ui() 216 title = abspath(name) 217 view_text(screen, title, slurp(name), quit_set, help_key) 218 219 exit_ui(screen, None) 220 return 0 221 except KeyboardInterrupt: 222 exit_ui(screen, None) 223 return 1 224 except Exception as e: 225 exit_ui(screen, f'\x1b[31m{e}\x1b[0m') 226 return 1 227 228 229 def slurp(name: str) -> str: 230 protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:') 231 seems_url = any(name.startswith(p) for p in protocols) 232 233 if seems_url: 234 from urllib.request import urlopen 235 with urlopen(name) as inp: 236 return inp.read().decode('utf-8') 237 238 with open(name) as inp: 239 return inp.read() 240 241 242 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'): 243 print(info.strip(), file=stderr) 244 exit(0) 245 246 if len(argv) > 2: 247 print(info.strip(), file=stderr) 248 msg = 'there can only be one (optional) starting-folder argument' 249 print(f'\x1b[31m{msg}\x1b[0m', file=stderr) 250 exit(4) 251 252 # avoid func curses.wrapper, since it calls func curses.start_color, which in 253 # turn forces a black background even on terminals configured to use any other 254 # background color 255 256 if len(argv) == 2: 257 exit(run(argv[1])) 258 else: 259 exit(run('-'))