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