File: vt.py
   1 #!/usr/bin/python
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright (c) 2026 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     A_REVERSE,
  29 )
  30 from os import dup2
  31 from sys import argv, stderr, stdin
  32 
  33 
  34 info = '''
  35 vt [options...] [file...]
  36 
  37 
  38 View Text is a (UTF-8) plain-text file viewer; when not given a filename,
  39 it reads text from the standard input, and only starts showing it when all
  40 reading is done.
  41 
  42 
  43     Escape     Quit this app
  44     F1         Toggle help-message screen; the Escape key also quits it
  45     F10        Quit this app
  46     F12        Quit this app
  47 
  48     Left       Scroll left, when any lines are wider than the screen
  49     Right      Scroll right, when any lines are wider than the screen
  50 
  51     Home       Go to the first line
  52     End        Go to the end, showing the last lines
  53     Up         Scroll 1 line up
  54     Down       Scroll 1 line down
  55     Page Up    Scroll 1 screen up
  56     Page Down  Scroll 1 screen down
  57 
  58 
  59 All (optional) leading options start with either single or double-dash:
  60 
  61     -h, -help    show this help message
  62 '''
  63 
  64 
  65 class TextViewerTUI:
  66     '''
  67     This is a scrollable viewer for plain-text content. After initializing it
  68     with a TUI screen value, you can configure various fields, before running
  69     it by calling method `run`:
  70         - title, which is shown at the top in reverse-style
  71         - tab_stop, which controls how tabs are turned into spaces
  72         - side_step, which controls the speed of lateral side-scrolling
  73         - handlers, which has all ncurses key-bindings for the viewer
  74     '''
  75 
  76     def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')):
  77         'Optional argument controls which ncurses keys quit the viewer.'
  78 
  79         self.title = ''
  80         self.tab_stop = 4
  81         self.side_step = 1
  82         self.handlers = {
  83             'KEY_RESIZE': lambda: self._on_resize(),
  84             'KEY_UP': lambda: self._on_up(),
  85             'KEY_DOWN': lambda: self._on_down(),
  86             'KEY_NPAGE': lambda: self._on_page_down(),
  87             'KEY_PPAGE': lambda: self._on_page_up(),
  88             'KEY_HOME': lambda: self._on_home(),
  89             'KEY_END': lambda: self._on_end(),
  90             'KEY_LEFT': lambda: self._on_left(),
  91             'KEY_RIGHT': lambda: self._on_right(),
  92         }
  93         if quit_set:
  94             for k in quit_set:
  95                 self.handlers[k] = None
  96 
  97         self._screen = screen
  98         self._inner_width = 0
  99         self._inner_height = 0
 100         self._max_line_width = 0
 101         self._top = 0
 102         self._left = 0
 103         self._max_top = 0
 104         self._max_left = 0
 105         self._lines = tuple()
 106 
 107     def run(self, content):
 108         'Interactively view/browse the string/strings given.'
 109 
 110         if isinstance(content, BaseException):
 111             self._on_resize()
 112             self._show_error(content)
 113             self._screen.getkey()
 114             return
 115 
 116         ts = self.tab_stop
 117         if isinstance(content, str):
 118             self._lines = tuple(l.expandtabs(ts) for l in content.splitlines())
 119         else:
 120             self._lines = tuple(l.expandtabs(ts) for l in content)
 121         content = '' # try to deallocate a few MBs when viewing big files
 122 
 123         if len(self._lines) == 0:
 124             self._max_line_width = 0
 125         else:
 126             self._max_line_width = max(len(l) for l in self._lines)
 127         self._on_resize()
 128 
 129         iw = self._inner_width
 130         ih = self._inner_height
 131 
 132         if iw < 10 or ih < 10:
 133             return
 134 
 135         while True:
 136             self._redraw()
 137             k = self._screen.getkey()
 138             if self.handlers and (k in self.handlers):
 139                 h = self.handlers[k]
 140                 if (h is None) or (h() is False):
 141                     self._lines = tuple()
 142                     return k
 143 
 144     def _fit_string(self, s):
 145         maxlen = max(self._inner_width, 0)
 146         return s if len(s) <= maxlen else s[:maxlen]
 147 
 148     def _redraw(self):
 149         title = self._fit_string(self.title)
 150         lines = self._lines
 151         screen = self._screen
 152         iw = self._inner_width
 153         ih = self._inner_height
 154 
 155         if iw < 10 or ih < 10:
 156             return
 157 
 158         screen.erase()
 159 
 160         if title:
 161             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 162 
 163         from math import ceil, log10
 164 
 165         at_bottom = len(self._lines) - self._top <= ih
 166         w = int(ceil(log10(len(lines)))) if len(lines) > 0 else 1
 167         if at_bottom:
 168             msg = '(empty)'
 169             if len(lines) > 0:
 170                 msg = f'END ({self._top + 1:>{w},} / {len(lines):,})'
 171         else:
 172             msg = f'({self._top + 1:>{w},} / {len(lines):,})'
 173         screen.addstr(0, iw - len(msg), self._fit_string(msg), A_REVERSE)
 174 
 175         from itertools import islice
 176 
 177         for i, l in enumerate(islice(lines, self._top, self._top + ih)):
 178             if self._left > 0:
 179                 l = l[self._left:]
 180             try:
 181                 screen.addnstr(i + 1, 0, l, iw)
 182             except KeyboardInterrupt as e:
 183                 raise e
 184             except Exception:
 185                 # some utf-8 files have lines which upset func addstr
 186                 screen.addnstr(i + 1, 0, '?' * len(l), iw)
 187 
 188         # show up/down arrows
 189         if self._top > 0:
 190             self._screen.addstr(1, iw - 1, '')
 191         if self._top < self._max_top:
 192             self._screen.addstr(ih, iw - 1, '')
 193 
 194         screen.refresh()
 195 
 196     def _show_error(self, err):
 197         title = self._fit_string(self.title)
 198         screen = self._screen
 199         iw = self._inner_width
 200         ih = self._inner_height
 201 
 202         if iw < 10 or ih < 10:
 203             return
 204 
 205         screen.erase()
 206         if title:
 207             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 208         screen.addstr(2, 0, self._fit_string(str(err)), A_REVERSE)
 209         screen.refresh()
 210 
 211     def _on_resize(self):
 212         height, width = self._screen.getmaxyx()
 213         self._inner_width = width - 1
 214         self._inner_height = height - 1
 215         self._max_top = max(len(self._lines) - self._inner_height, 0)
 216         ss = self.side_step
 217         self._max_left = self._max_line_width - self._inner_width - 1 + ss
 218         self._max_left = max(self._max_left, 0)
 219 
 220     def _on_up(self):
 221         self._top = max(self._top - 1, 0)
 222 
 223     def _on_down(self):
 224         self._top = min(self._top + 1, self._max_top)
 225 
 226     def _on_page_up(self):
 227         self._top = max(self._top - self._inner_height, 0)
 228 
 229     def _on_page_down(self):
 230         self._top = min(self._top + self._inner_height, self._max_top)
 231 
 232     def _on_home(self):
 233         self._top = 0
 234 
 235     def _on_end(self):
 236         self._top = self._max_top
 237 
 238     def _on_left(self):
 239         self._left = max(self._left - self.side_step, 0)
 240 
 241     def _on_right(self):
 242         self._left = min(self._left + self.side_step, self._max_left)
 243 
 244 
 245 def slurp_file(name):
 246     try:
 247         with open(name, 'r') as inp:
 248             return inp.read()
 249     except KeyboardInterrupt as e:
 250         raise e
 251     except Exception as e:
 252         return e
 253 
 254 
 255 def view(title, content):
 256     # save memory by clearing the variable holding the slurped string: when
 257     # input is big enough (say hundreds of MBs), the difference is noticeable
 258     def free_mem(s):
 259         nonlocal content
 260         content = ''
 261         return s
 262 
 263     # keep original stdin as /dev/fd/3
 264     dup2(0, 3)
 265 
 266     # make TUI work even when contents came from the standard input
 267     with open('/dev/tty', 'rb') as inp:
 268         dup2(inp.fileno(), 0)
 269 
 270     screen = initscr()
 271     savetty()
 272     noecho()
 273     cbreak()
 274     screen.keypad(True)
 275     curs_set(0)
 276     set_escdelay(10)
 277 
 278     def stop():
 279         resetty()
 280         endwin()
 281         # restore original stdin
 282         dup2(3, 0)
 283 
 284     try:
 285         tv = TextViewerTUI(screen)
 286         tv.title = title
 287         tv.side_step = 4
 288         tv.handlers['KEY_F(1)'] = lambda: show_help(screen)
 289         tv.run(free_mem(content))
 290     except KeyboardInterrupt as e:
 291         stop()
 292         raise e
 293     except Exception as e:
 294         stop()
 295         raise e
 296     stop()
 297 
 298 
 299 def view_file(name):
 300     try:
 301         if name == '-':
 302             view('<stdin>', stdin.read())
 303         else:
 304             view(name, slurp_file(name))
 305         return 0
 306     except KeyboardInterrupt:
 307         return 1
 308     except Exception as e:
 309         # raise e
 310         print(str(e), file=stderr)
 311         return 1
 312 
 313 
 314 def show_help(screen):
 315     quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b', 'KEY_F(1)')
 316     h = TextViewerTUI(screen, quit_set)
 317     h.title = 'Help for `vt`'
 318     return h.run(info.strip()) != '\x1b'
 319 
 320 
 321 args = argv[1:]
 322 if len(args) > 0 and args[0] in ('-h', '--h', '-help', '--help'):
 323     print(info.strip())
 324     exit(0)
 325 
 326 if len(args) > 1 and args[0] == '--':
 327     args = args[1:]
 328 
 329 if len(args) > 1:
 330     print('multiple files: can only view 1 file at a time', file=stderr)
 331     exit(1)
 332 
 333 name = args[0] if len(args) == 1 else '-'
 334 exit(view_file(name))