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, \ 28 set_escdelay, 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, stdout 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 start 52 End Go to the end 53 Up Go to the previous page 54 Down Go to the next page 55 Page Up Go to the previous page 56 Page Down Go to the next page 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 70 ansi_re = compile('\x1b\\[[0-9;]*[A-Za-z]') 71 72 # tabstop is the max number of spaces tabs are replaced with 73 tabstop = 4 74 75 # sep is the separator/joiner of all side-by-side columns 76 sep = ' █ ' 77 78 79 def layout(x: Tuple[str], maxw: int, ncols: int, h: int, i: int = 0): 80 def index(k: int) -> str: 81 return x[k] if k < len(x) else '' 82 83 def pad(s: str) -> str: 84 return f'{s:{maxw + ansi_length(s)}}' 85 86 for j in range(h): 87 yield tuple(pad(index(i + j + k * h)) for k in range(ncols)) 88 89 90 def shortened(s: str, maxlen: int, trail: str = '') -> str: 91 maxlen = max(maxlen, 0) 92 return s if len(s) <= maxlen else s[:maxlen - len(trail)] + trail 93 94 95 def fix(s: str) -> str: 96 return ansi_re.sub('', s).expandtabs(tabstop) 97 98 99 def ansi_length(s: str) -> int: 100 return sum(m.end() - m.start() for m in ansi_re.finditer(s)) 101 102 103 def view(scr, title: str, text: str, quit_set, help = None) -> Tuple[int, int]: 104 screen = scr 105 lines = tuple(fix(l) for l in text.splitlines()) 106 text = '' # try to deallocate a few MBs when viewing big files 107 108 start = 0 109 maxw = 0 110 if len(lines) > 0: 111 maxw = max(len(l) - ansi_length(l) for l in lines) 112 h, w = screen.getmaxyx() 113 114 def find_num_cols(wanted: int, maxw: int, w: int) -> int: 115 n = wanted 116 if (maxw + 3) * n > w: 117 n = int(w / (maxw + 3)) 118 return n if n > 0 else 1 119 120 # wanted_ncols = find_num_cols(2, maxw, w) 121 wanted_ncols = 2 122 123 # call func on_resize to properly initialize these variables 124 max_start = inner_width = inner_height = step = 0 125 126 def on_resize() -> None: 127 nonlocal h, w, max_start, inner_width, inner_height, step 128 h, w = screen.getmaxyx() 129 inner_width = w - 1 130 inner_height = h - 1 131 nc = find_num_cols(wanted_ncols, maxw, w) 132 step = nc * inner_height 133 max_start = max(len(lines) - step, 0) 134 135 def scroll(to: int) -> None: 136 nonlocal start 137 start = to 138 139 # initialize local variables 140 on_resize() 141 142 handlers: Dict[str, Callable] = { 143 'KEY_RESIZE': on_resize, 144 'KEY_UP': lambda: scroll(max(start - step, 0)), 145 'KEY_DOWN': lambda: scroll(min(start + step, max_start)), 146 'KEY_NPAGE': lambda: scroll(min(start + step, max_start)), 147 'KEY_PPAGE': lambda: scroll(max(start - step, 0)), 148 'KEY_HOME': lambda: scroll(0), 149 'KEY_END': lambda: scroll(max_start), 150 } 151 152 if help: 153 handlers[help] = lambda: view_help(screen) 154 155 def update() -> None: 156 # screen.erase() 157 screen.clear() 158 159 screen.addstr(0, 0, shortened(title, inner_width), A_REVERSE) 160 at_bottom = len(lines) - start <= step 161 if at_bottom: 162 msg = f'END ({len(lines):,})' if len(lines) > 0 else '(empty)' 163 else: 164 msg = f'({start + 1:,} / {len(lines):,})' 165 screen.addstr(0, w - 1 - len(msg), shortened(msg, inner_width)) 166 167 ih = inner_height 168 nc = find_num_cols(wanted_ncols, maxw, w) 169 170 for i, row in enumerate(layout(lines, maxw, nc, ih, start)): 171 try: 172 line = sep.join(row).rstrip() 173 screen.addnstr(i + 1, 0, line, inner_width) 174 except Exception: 175 # some utf-8 files have lines which upset func addstr 176 pass 177 178 # add up/down scrolling arrows 179 try: 180 if start > 0: 181 screen.addstr(1, w - 1, '▲') 182 if start < max_start: 183 screen.addstr(h - 1, w - 1, '▼') 184 except: 185 pass 186 187 screen.refresh() 188 189 while True: 190 update() 191 192 k = screen.getkey() 193 if k in handlers: 194 handlers[k]() 195 continue 196 197 if k.isdigit(): 198 n = int(k) 199 wanted_ncols = n if n != 0 else 10 200 on_resize() 201 continue 202 203 if k in quit_set: 204 # screen.erase() 205 # screen.clear() 206 # return wanted_ncols, h 207 return find_num_cols(wanted_ncols, maxw, w), h 208 209 210 def view_help(screen) -> None: 211 title = 'Help for View like a Book (vb)' 212 quit_set = ('\x1b', 'KEY_F(1)', 'KEY_F(10)') 213 view(screen, title, info.strip(), quit_set) 214 215 216 def enter_ui(): 217 # keep original stdout as /dev/fd/3 218 dup2(1, 3) 219 # separate live output from final (optional) result on stdout 220 with open('/dev/tty', 'rb') as newin, open('/dev/tty', 'wb') as newout: 221 dup2(newin.fileno(), 0) 222 dup2(newout.fileno(), 1) 223 224 screen = initscr() 225 savetty() 226 noecho() 227 cbreak() 228 screen.keypad(True) 229 curs_set(0) 230 set_escdelay(10) 231 return screen 232 233 234 def exit_ui(screen, error_msg) -> None: 235 if screen: 236 resetty() 237 endwin() 238 239 if error_msg: 240 # stderr is never tampered with 241 print(error_msg, file=stderr) 242 243 244 def run(name: str) -> None: 245 screen = None 246 help_key = 'KEY_F(1)' 247 quit_set = ('\x1b', 'KEY_F(10)') 248 piped = not stdout.isatty() 249 250 try: 251 text = '' 252 # save memory by clearing the variable holding the slurped string 253 def free_mem(res: str) -> str: 254 nonlocal text 255 text = '' 256 return res 257 f = (lambda x: x) if piped else free_mem 258 259 if name == '-': 260 # can read piped input only before entering the `ui-mode` 261 text = stdin.read() 262 name = '<stdin>' 263 screen = enter_ui() 264 n, h = view(screen, name, f(text), quit_set, help_key) 265 else: 266 screen = enter_ui() 267 title = abspath(name) 268 text = slurp(name) 269 n, h = view(screen, title, f(text), quit_set, help_key) 270 271 exit_ui(screen, None) 272 if piped: 273 # func enter_ui kept original stdout as /dev/fd/3 274 with open('/dev/fd/3', 'w') as out: 275 show_layout(out, text, n, h) 276 return 0 277 except KeyboardInterrupt: 278 exit_ui(screen, None) 279 return 1 280 except Exception as e: 281 exit_ui(screen, f'\x1b[31m{e}\x1b[0m') 282 return 1 283 284 285 def slurp(name: str) -> str: 286 protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:') 287 seems_url = any(name.startswith(p) for p in protocols) 288 289 if seems_url: 290 from urllib.request import urlopen 291 with urlopen(name) as inp: 292 return inp.read().decode('utf-8') 293 294 with open(name) as inp: 295 return inp.read() 296 297 298 def show_layout(out, text: str, nc: int, h: int) -> None: 299 maxw = 0 300 lines = tuple(l.expandtabs(tabstop) for l in text.splitlines()) 301 if len(lines) > 0: 302 maxw = max(len(l) - ansi_length(l) for l in lines) 303 304 inner_height = h - 1 305 per_page = nc * inner_height 306 w = maxw * nc + len(sep) * (nc - 1) 307 bottom = '·' * w 308 309 for start in range(0, len(lines), per_page): 310 for row in layout(lines, maxw, nc, inner_height, start): 311 print(sep.join(row).rstrip(), file=out) 312 print(bottom, file=out) 313 out.flush() 314 315 316 317 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'): 318 print(info.strip()) 319 exit(0) 320 321 if len(argv) > 2: 322 print(info.strip(), file=stderr) 323 msg = 'there can only be one (optional) starting-folder argument' 324 print(f'\x1b[31m{msg}\x1b[0m', file=stderr) 325 exit(4) 326 327 # avoid func curses.wrapper, since it calls func curses.start_color, which in 328 # turn forces a black background even on terminals configured to use any other 329 # background color 330 331 exit(run(argv[1] if len(argv) == 2 else '-'))