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, \
  28     set_escdelay, A_NORMAL, A_REVERSE
  29 from itertools import islice
  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 The only options are one of the help options, which show this message and
  60 quit right away, without using the interactive mode: these are `-h`, `--h`,
  61 `-help`, and `--help`, without the quotes.
  62 '''
  63 
  64 
  65 class SimpleTUI:
  66     '''
  67     Manager to start/stop a no-color text user-interface (TUI), allowing for
  68     standard input/output to be used normally before method `start` is called
  69     and after method `stop` is called. After calling is method `start`, its
  70     field `screen` has the ncurses value for all the interactive input-output.
  71     You can also call method `wrapper` with a callback function accepting the
  72     `screen` ncurses value.
  73     '''
  74 
  75     def __init__(self):
  76         self.screen = None
  77 
  78     def start(self, out_fd = -1, esc_delay = -1):
  79         '''
  80         Start interactive-mode: the first optional argument should be more
  81         than 2, if given, since it would mess with stdio, which is precisely
  82         what it's meant to avoid doing.
  83         '''
  84 
  85         if out_fd >= 0:
  86             # keep original stdout as /dev/fd/...
  87             dup2(1, out_fd)
  88             # separate live output from final (optional) result on stdout
  89             with open('/dev/tty', 'rb') as inp, open('/dev/tty', 'wb') as out:
  90                 dup2(inp.fileno(), 0)
  91                 dup2(out.fileno(), 1)
  92 
  93         self.screen = initscr()
  94         savetty()
  95         noecho()
  96         cbreak()
  97         self.screen.keypad(True)
  98         curs_set(0)
  99         if esc_delay >= 0:
 100             set_escdelay(esc_delay)
 101 
 102     def stop(self):
 103         'Stop interactive-mode.'
 104 
 105         if self.screen:
 106             resetty()
 107             endwin()
 108 
 109     def wrapper(self, func, out_fd = -1, esc_delay = -1):
 110         'Run a function accepting the screen value for all TUI interactions.'
 111 
 112         try:
 113             self.start(out_fd, esc_delay)
 114             func(self.screen)
 115         except KeyboardInterrupt:
 116             pass
 117         finally:
 118             self.stop()
 119 
 120 
 121 class TextViewerTUI:
 122     '''
 123     This is a scrollable viewer for plain-text content. After initializing it
 124     with a TUI screen value, you can configure various fields, before running
 125     it by calling method `run`:
 126         - title, which is shown at the top in reverse-style
 127         - tab_stop, which controls how tabs are turned into spaces
 128         - side_step, which controls the speed of lateral side-scrolling
 129         - handlers, which has all ncurses key-bindings for the viewer
 130     '''
 131 
 132     def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')):
 133         'Optional argument controls which ncurses keys quit the viewer.'
 134 
 135         self.title = ''
 136         self.tab_stop = 4
 137         self.side_step = 1
 138         self.handlers = {
 139             'KEY_RESIZE': lambda: self._on_resize(),
 140             'KEY_UP': lambda: self._on_up(),
 141             'KEY_DOWN': lambda: self._on_down(),
 142             'KEY_NPAGE': lambda: self._on_page_down(),
 143             'KEY_PPAGE': lambda: self._on_page_up(),
 144             'KEY_HOME': lambda: self._on_home(),
 145             'KEY_END': lambda: self._on_end(),
 146             'KEY_LEFT': lambda: self._on_left(),
 147             'KEY_RIGHT': lambda: self._on_right(),
 148         }
 149         if quit_set:
 150             for k in quit_set:
 151                 self.handlers[k] = None
 152 
 153         self._screen = screen
 154         self._inner_width = 0
 155         self._inner_height = 0
 156         self._max_line_width = 0
 157         self._top = 0
 158         self._left = 0
 159         self._max_top = 0
 160         self._max_left = 0
 161         self._lines = tuple()
 162 
 163     def run(self, content):
 164         'Interactively view/browse the string/strings given.'
 165 
 166         if isinstance(content, BaseException):
 167             self._on_resize()
 168             self._show_error(content)
 169             self._screen.getkey()
 170             return
 171 
 172         ts = self.tab_stop
 173         if isinstance(content, str):
 174             self._lines = tuple(l.expandtabs(ts) for l in content.splitlines())
 175         else:
 176             self._lines = tuple(l.expandtabs(ts) for l in content)
 177         content = '' # try to deallocate a few MBs when viewing big files
 178 
 179         if len(self._lines) == 0:
 180             self._max_line_width = 0
 181         else:
 182             self._max_line_width = max(len(l) for l in self._lines)
 183         self._on_resize()
 184 
 185         iw = self._inner_width
 186         ih = self._inner_height
 187 
 188         if iw < 10 or ih < 10:
 189             return
 190 
 191         while True:
 192             self._redraw()
 193             k = self._screen.getkey()
 194             if self.handlers and (k in self.handlers):
 195                 h = self.handlers[k]
 196                 if (h is None) or (h() is False):
 197                     self._lines = tuple()
 198                     return k
 199 
 200     def _fit_string(self, s):
 201         maxlen = max(self._inner_width, 0)
 202         return s if len(s) <= maxlen else s[:maxlen]
 203 
 204     def _redraw(self):
 205         title = self._fit_string(self.title)
 206         lines = self._lines
 207         screen = self._screen
 208         iw = self._inner_width
 209         ih = self._inner_height
 210 
 211         if iw < 10 or ih < 10:
 212             return
 213 
 214         screen.erase()
 215 
 216         if title:
 217             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 218 
 219         at_bottom = len(self._lines) - self._top <= ih
 220         if at_bottom:
 221             msg = f'END ({len(lines):,})' if len(lines) > 0 else '(empty)'
 222         else:
 223             msg = f'({self._top + 1:,} / {len(lines):,})'
 224         screen.addstr(0, iw - len(msg), self._fit_string(msg), A_REVERSE)
 225 
 226         for i, l in enumerate(islice(lines, self._top, self._top + ih)):
 227             if self._left > 0:
 228                 l = l[self._left:]
 229             try:
 230                 screen.addnstr(i + 1, 0, l, iw)
 231             except Exception:
 232                 # some utf-8 files have lines which upset func addstr
 233                 screen.addnstr(i + 1, 0, '?' * len(l), iw)
 234 
 235         # show up/down arrows
 236         if self._top > 0:
 237             self._screen.addstr(1, iw - 1, '')
 238         if self._top < self._max_top:
 239             self._screen.addstr(ih, iw - 1, '')
 240 
 241         screen.refresh()
 242 
 243     def _show_error(self, err):
 244         title = self._fit_string(self.title)
 245         screen = self._screen
 246         iw = self._inner_width
 247         ih = self._inner_height
 248 
 249         if iw < 10 or ih < 10:
 250             return
 251 
 252         screen.erase()
 253         if title:
 254             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 255         screen.addstr(2, 0, self._fit_string(str(err)), A_REVERSE)
 256         screen.refresh()
 257 
 258     def _on_resize(self):
 259         height, width = self._screen.getmaxyx()
 260         self._inner_width = width - 1
 261         self._inner_height = height - 1
 262         self._max_top = max(len(self._lines) - self._inner_height, 0)
 263         ss = self.side_step
 264         self._max_left = self._max_line_width - self._inner_width - 1 + ss
 265         self._max_left = max(self._max_left, 0)
 266 
 267     def _on_up(self):
 268         self._top = max(self._top - 1, 0)
 269 
 270     def _on_down(self):
 271         self._top = min(self._top + 1, self._max_top)
 272 
 273     def _on_page_up(self):
 274         self._top = max(self._top - self._inner_height, 0)
 275 
 276     def _on_page_down(self):
 277         self._top = min(self._top + self._inner_height, self._max_top)
 278 
 279     def _on_home(self):
 280         self._top = 0
 281 
 282     def _on_end(self):
 283         self._top = self._max_top
 284 
 285     def _on_left(self):
 286         self._left = max(self._left - self.side_step, 0)
 287 
 288     def _on_right(self):
 289         self._left = min(self._left + self.side_step, self._max_left)
 290 
 291 
 292 def slurp_file(name):
 293     try:
 294         with open(name, 'r') as inp:
 295             return inp.read()
 296     except Exception as e:
 297         return e
 298 
 299 
 300 def view(title, content):
 301     # save memory by clearing the variable holding the slurped string
 302     def free_mem(s):
 303         nonlocal content
 304         content = ''
 305         return s
 306 
 307     tui = SimpleTUI()
 308     tui.start(3, 10)
 309     tv = TextViewerTUI(tui.screen)
 310     tv.title = name
 311     tv.side_step = 4
 312     tv.handlers['KEY_F(1)'] = lambda: show_help(tui)
 313     tv.run(free_mem(content))
 314     tui.stop()
 315 
 316 
 317 def view_file(name):
 318     try:
 319         if name == '-':
 320             view('<stdin>', stdin.read())
 321         else:
 322             view(name, slurp_file(name))
 323         return 0
 324     except KeyboardInterrupt:
 325         return 1
 326     except Exception as e:
 327         print(str(e), file=stderr)
 328         return 1
 329 
 330 
 331 def show_help(screen):
 332     quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b', 'KEY_F(1)')
 333     h = TextViewerTUI(screen, quit_set)
 334     h.title = 'Help for `Tinker`'
 335     return h.run(info.strip()) != '\x1b'
 336 
 337 
 338 args = argv[1:]
 339 if len(args) > 0 and args[0] in ('-h', '--h', '-help', '--help'):
 340     print(info.strip())
 341     exit(0)
 342 
 343 if len(args) > 1 and args[0] == '--':
 344     args = args[1:]
 345 
 346 if len(args) > 1:
 347     print('can only view 1 file', file=stderr)
 348     exit(1)
 349 
 350 name = args[0] if len(args) == 1 else '-'
 351 exit(view_file(name))