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 # return wanted_ncols, h 205 return find_num_cols(wanted_ncols, maxw, w), h 206 207 208 def view_help(screen) -> None: 209 title = 'Help for View like a Book (vb)' 210 quit_set = ('\x1b', 'KEY_F(1)', 'KEY_F(10)') 211 view(screen, title, info.strip(), quit_set) 212 213 214 def enter_ui(): 215 # keep original stdout as /dev/fd/3 216 dup2(1, 3) 217 # separate live output from final (optional) result on stdout 218 with open('/dev/tty', 'rb') as newin, open('/dev/tty', 'wb') as newout: 219 dup2(newin.fileno(), 0) 220 dup2(newout.fileno(), 1) 221 222 screen = initscr() 223 savetty() 224 noecho() 225 cbreak() 226 screen.keypad(True) 227 curs_set(0) 228 set_escdelay(10) 229 return screen 230 231 232 def exit_ui(screen, error_msg) -> None: 233 if screen: 234 resetty() 235 endwin() 236 237 if error_msg: 238 # stderr is never tampered with 239 print(error_msg, file=stderr) 240 241 242 def run(name: str) -> None: 243 screen = None 244 help_key = 'KEY_F(1)' 245 quit_set = ('\x1b', 'KEY_F(10)') 246 piped = not stdout.isatty() 247 248 try: 249 text = '' 250 # save memory by clearing the variable holding the slurped string 251 def free_mem(res: str) -> str: 252 nonlocal text 253 text = '' 254 return res 255 f = (lambda x: x) if piped else free_mem 256 257 if name == '-': 258 # can read piped input only before entering the `ui-mode` 259 text = stdin.read() 260 name = '<stdin>' 261 screen = enter_ui() 262 n, h = view(screen, name, f(text), quit_set, help_key) 263 else: 264 screen = enter_ui() 265 title = abspath(name) 266 text = slurp(name) 267 n, h = view(screen, title, f(text), quit_set, help_key) 268 269 exit_ui(screen, None) 270 if piped: 271 # func enter_ui kept original stdout as /dev/fd/3 272 with open('/dev/fd/3', 'w') as out: 273 show_layout(out, text, n, h) 274 out.flush() 275 return 0 276 except KeyboardInterrupt: 277 exit_ui(screen, None) 278 return 1 279 except Exception as e: 280 exit_ui(screen, f'\x1b[31m{e}\x1b[0m') 281 return 1 282 283 284 def slurp(name: str) -> str: 285 protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:') 286 seems_url = any(name.startswith(p) for p in protocols) 287 288 if seems_url: 289 from urllib.request import urlopen 290 with urlopen(name) as inp: 291 return inp.read().decode('utf-8') 292 293 with open(name) as inp: 294 return inp.read() 295 296 297 def show_layout(out, text: str, nc: int, h: int) -> None: 298 maxw = 0 299 lines = tuple(l.expandtabs(tabstop) for l in text.splitlines()) 300 if len(lines) > 0: 301 maxw = max(len(l) - ansi_length(l) for l in lines) 302 303 inner_height = h - 1 304 per_page = nc * inner_height 305 w = maxw * nc + len(sep) * (nc - 1) 306 bottom = '·' * w 307 308 for start in range(0, len(lines), per_page): 309 for row in layout(lines, maxw, nc, inner_height, start): 310 print(sep.join(row).rstrip(), file=out) 311 print(bottom, file=out) 312 313 314 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'): 315 print(info.strip()) 316 exit(0) 317 318 if len(argv) > 2: 319 print(info.strip(), file=stderr) 320 msg = 'there can only be one (optional) starting-folder argument' 321 print(f'\x1b[31m{msg}\x1b[0m', file=stderr) 322 exit(4) 323 324 # avoid func curses.wrapper, since it calls func curses.start_color, which in 325 # turn forces a black background even on terminals configured to use any other 326 # background color 327 328 exit(run(argv[1] if len(argv) == 2 else '-'))