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 '-'))