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, stdout
  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.file_name = ''
  81         self.tab_stop = 4
  82         self.side_step = 1
  83 
  84         self.handlers = {
  85             'KEY_RESIZE': lambda: self._on_resize(),
  86             'KEY_UP': lambda: self._on_up(),
  87             'KEY_DOWN': lambda: self._on_down(),
  88             'KEY_NPAGE': lambda: self._on_page_down(),
  89             'KEY_PPAGE': lambda: self._on_page_up(),
  90             'KEY_HOME': lambda: self._on_home(),
  91             'KEY_END': lambda: self._on_end(),
  92             'KEY_LEFT': lambda: self._on_left(),
  93             'KEY_RIGHT': lambda: self._on_right(),
  94             'KEY_F(5)': lambda: self._on_reload(),
  95         }
  96 
  97         if quit_set:
  98             for k in quit_set:
  99                 self.handlers[k] = None
 100 
 101         self._screen = screen
 102         self._inner_width = 0
 103         self._inner_height = 0
 104         self._max_line_width = 0
 105         self._top = 0
 106         self._left = 0
 107         self._max_top = 0
 108         self._max_left = 0
 109         self._lines = tuple()
 110 
 111     def load_file(self):
 112         if self.file_name == '':
 113             return
 114 
 115         with open(self.file_name, 'r') as inp:
 116             content = inp.read()
 117         self.split_lines(content)
 118 
 119         mlw = 0
 120         if len(self._lines) > 0:
 121             mlw = max(len(l) for l in self._lines)
 122         self._max_line_width = mlw
 123 
 124     def split_lines(self, content):
 125         ts = self.tab_stop
 126         if isinstance(content, str):
 127             self._lines = tuple(l.expandtabs(ts) for l in content.splitlines())
 128         else:
 129             self._lines = tuple(l.expandtabs(ts) for l in content)
 130 
 131     def run(self, content = None):
 132         'Interactively view/browse the string/strings given.'
 133 
 134         if isinstance(content, bytes):
 135             self._on_resize()
 136             return self._run_bin(content)
 137 
 138         if isinstance(content, BaseException):
 139             self._on_resize()
 140             self._show_error(content)
 141             return self._screen.getkey()
 142 
 143         if content is None:
 144             self.load_file()
 145         else:
 146             self.split_lines(content)
 147         content = '' # try to deallocate a few MBs when viewing big files
 148 
 149         self._on_resize()
 150 
 151         iw = self._inner_width
 152         ih = self._inner_height
 153 
 154         if iw < 10 or ih < 10:
 155             return
 156 
 157         while True:
 158             self._redraw()
 159             k = self._screen.getkey()
 160             if self.handlers and (k in self.handlers):
 161                 h = self.handlers[k]
 162                 if (h is None) or (h() is False):
 163                     self._lines = tuple()
 164                     return k
 165 
 166     def _run_bin(self, header):
 167         # header = header[:128]
 168         title = self._fit_string(self.title)
 169         screen = self._screen
 170         iw = self._inner_width
 171         ih = self._inner_height
 172 
 173         if iw < 10 or ih < 10:
 174             return None
 175 
 176         screen.erase()
 177         kind = self._detect_type(header)
 178 
 179         # save memory by clearing the variable holding the slurped bytes: when
 180         # data are big enough (say hundreds of MBs), the result is noticeable
 181         header = None
 182 
 183         if title:
 184             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 185         screen.addstr(2, 0, self._fit_string(kind), A_REVERSE)
 186         screen.refresh()
 187         return self._screen.getkey()
 188 
 189     def _fit_string(self, s):
 190         maxlen = max(self._inner_width, 0)
 191         return s if len(s) <= maxlen else s[:maxlen]
 192 
 193     def _redraw(self):
 194         title = self._fit_string(self.title)
 195         lines = self._lines
 196         screen = self._screen
 197         iw = self._inner_width
 198         ih = self._inner_height
 199 
 200         if iw < 10 or ih < 10:
 201             return
 202 
 203         screen.erase()
 204 
 205         if title:
 206             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 207 
 208         from math import ceil, log10
 209 
 210         at_bottom = len(self._lines) - self._top <= ih
 211         w = int(ceil(log10(len(lines)))) if len(lines) > 0 else 1
 212         if at_bottom:
 213             msg = '(empty)'
 214             if len(lines) > 0:
 215                 msg = f'END ({self._top + 1:>{w},} / {len(lines):,})'
 216         else:
 217             msg = f'({self._top + 1:>{w},} / {len(lines):,})'
 218         screen.addstr(0, iw - len(msg), self._fit_string(msg), A_REVERSE)
 219 
 220         from itertools import islice
 221 
 222         for i, l in enumerate(islice(lines, self._top, self._top + ih)):
 223             if self._left > 0:
 224                 l = l[self._left:]
 225             try:
 226                 screen.addnstr(i + 1, 0, l, iw)
 227             except Exception as _:
 228                 # some utf-8 files have lines which upset func addstr
 229                 screen.addnstr(i + 1, 0, '?' * len(l), iw)
 230 
 231         # show up/down arrows
 232         if self._top > 0:
 233             self._screen.addstr(1, iw - 1, '▲')
 234         if self._top < self._max_top:
 235             self._screen.addstr(ih, iw - 1, '▼')
 236 
 237         screen.refresh()
 238 
 239     def _show_error(self, err):
 240         title = self._fit_string(self.title)
 241         screen = self._screen
 242         iw = self._inner_width
 243         ih = self._inner_height
 244 
 245         if iw < 10 or ih < 10:
 246             return
 247 
 248         screen.erase()
 249         if title:
 250             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 251         screen.addstr(2, 0, self._fit_string(str(err)), A_REVERSE)
 252         screen.refresh()
 253 
 254     def _on_resize(self):
 255         height, width = self._screen.getmaxyx()
 256         self._inner_width = width - 1
 257         self._inner_height = height - 1
 258         self._max_top = max(len(self._lines) - self._inner_height, 0)
 259         ss = self.side_step
 260         self._max_left = self._max_line_width - self._inner_width - 1 + ss
 261         self._max_left = max(self._max_left, 0)
 262 
 263     def _on_up(self):
 264         self._top = max(self._top - 1, 0)
 265 
 266     def _on_down(self):
 267         self._top = min(self._top + 1, self._max_top)
 268 
 269     def _on_page_up(self):
 270         self._top = max(self._top - self._inner_height, 0)
 271 
 272     def _on_page_down(self):
 273         self._top = min(self._top + self._inner_height, self._max_top)
 274 
 275     def _on_home(self):
 276         self._top = 0
 277 
 278     def _on_end(self):
 279         self._top = self._max_top
 280 
 281     def _on_left(self):
 282         self._left = max(self._left - self.side_step, 0)
 283 
 284     def _on_right(self):
 285         self._left = min(self._left + self.side_step, self._max_left)
 286 
 287     def _on_reload(self):
 288         self.load_file()
 289 
 290     def _detect_type(self, header):
 291         hdr_dispatch = {
 292             0x00: [
 293                 (b'\x00\x00\x01\xba', 'video/mpeg'),
 294                 (b'\x00\x00\x01\xb3', 'video/mpeg'),
 295                 (b'\x00\x00\x01\x00', 'image/x-icon'),
 296                 (b'\x00\x00\x02\x00', 'image/vnd.microsoft.icon'), # .cur files
 297                 (b'\x00asm', 'application/wasm'),
 298             ],
 299             0x1a: [(b'\x1a\x45\xdf\xa3', 'video/webm')], # general MKV format
 300             0x1f: [(b'\x1f\x8b\x08', 'application/gzip')],
 301             0x23: [
 302                 (b'#! ', 'text/plain; charset=UTF-8'),
 303                 (b'#!/', 'text/plain; charset=UTF-8'),
 304             ],
 305             0x25: [
 306                 (b'%PDF', 'application/pdf'),
 307                 (b'%!PS', 'application/postscript'),
 308             ],
 309             0x28: [(b'\x28\xb5\x2f\xfd', 'application/zstd')],
 310             0x2e: [(b'.snd', 'audio/basic')],
 311             0x47: [(b'GIF87a', 'image/gif'), (b'GIF89a', 'image/gif')],
 312             0x49: [
 313                 # some MP3s start with an ID3 meta-data section
 314                 (b'ID3\x02', 'audio/mpeg'),
 315                 (b'ID3\x03', 'audio/mpeg'),
 316                 (b'ID3\x04', 'audio/mpeg'),
 317                 (b'II*\x00', 'image/tiff'),
 318             ],
 319             0x4d: [(b'MM\x00*', 'image/tiff'), (b'MThd', 'audio/midi')],
 320             0x4f: [(b'OggS', 'audio/ogg')],
 321             0x50: [(b'PK\x03\x04', 'application/zip')],
 322             0x53: [(b'SQLite format 3\x00', 'application/x-sqlite3')],
 323             0x63: [(b'caff\x00\x01\x00\x00', 'audio/x-caf')],
 324             0x66: [(b'fLaC', 'audio/x-flac')],
 325             0x7b: [(b'{\\rtf', 'application/rtf')],
 326             0x7f: [(b'\x7fELF', 'application/x-elf')],
 327             0x89: [(b'\x89PNG\x0d\x0a\x1a\x0a', 'image/png')],
 328             0xff: [
 329                 (b'\xff\xd8\xff', 'image/jpeg'),
 330                 # handle common ways MP3 data start
 331                 (b'\xff\xf3\x48\xc4\x00', 'audio/mpeg'),
 332                 (b'\xff\xfb', 'audio/mpeg'),
 333             ],
 334         }
 335 
 336         # ftyp_types helps func match_ftyp auto-detect MPEG-4-like formats
 337         ftyp_types = (
 338             (b'M4A ', 'audio/aac'),
 339             (b'M4A\x00', 'audio/aac'),
 340             (b'mp42', 'video/x-m4v'),
 341             (b'dash', 'audio/aac'),
 342             (b'isom', 'video/mp4'),
 343             # (b'isom', 'audio/aac'),
 344             (b'MSNV', 'video/mp4'),
 345             (b'qt  ', 'video/quicktime'),
 346             (b'heic', 'image/heic'),
 347             (b'avif', 'image/avif'),
 348         )
 349 
 350         xmlish_heuristics = (
 351             (b'<html>', 'text/html'), (b'<html ', 'text/html'),
 352             (b'<head>', 'text/html'), (b'<head ', 'text/html'),
 353             (b'<body>', 'text/html'), (b'<body ', 'text/html'),
 354             (b'<!DOCTYPE html', 'text/html'),
 355             (b'<svg>', 'image/svg+xml'), (b'<svg ', 'image/svg+xml'),
 356             (b'<?xml>', 'application/xml'), (b'<?xml ', 'application/xml'),
 357         )
 358 
 359         from re import compile as comp
 360 
 361         json_heuristics = (
 362             comp(b'''^\\s*\\{\\s*"'''),
 363             comp(b'''^\\s*\\{\\s*\\['''),
 364             comp(b'''^\\s*\\[\\s*"'''),
 365             comp(b'''^\\s*\\[\\s*\\{'''),
 366             comp(b'''^\\s*\\[\\s*\\['''),
 367         )
 368 
 369         def exact_match(header: bytes, maybe: bytes) -> bool:
 370             enough_bytes = len(header) >= len(maybe)
 371             return enough_bytes and all(x == y for x, y in zip(header, maybe))
 372 
 373         def match_riff(header: bytes) -> str:
 374             if len(header) < 12 or not header.startswith(b'RIFF'):
 375                 return ''
 376 
 377             if header.find(b'WEBP', 8, 12) == 8:
 378                 return 'image/webp'
 379             if header.find(b'WAVE', 8, 12) == 8:
 380                 return 'audio/x-wav'
 381             if header.find(b'AVI ', 8, 12) == 8:
 382                 return 'video/avi'
 383             return ''
 384 
 385         def match_form(header: bytes) -> str:
 386             if len(header) < 12 or not header.startswith(b'FORM'):
 387                 return ''
 388 
 389             if header.find(b'AIFF', 8, 12) == 8:
 390                 return 'audio/aiff'
 391             if header.find(b'AIFC', 8, 12) == 8:
 392                 return 'audio/aiff'
 393             return ''
 394 
 395         def match_ftyp(header: bytes) -> str:
 396             # first 4 bytes can be anything, next 4 bytes must be ASCII 'ftyp'
 397             if len(header) < 12 or header.find(b'ftyp', 4, 8) != 4:
 398                 return ''
 399 
 400             # next 4 bytes after the ASCII 'ftyp' declare the data-format
 401             for marker, mime in ftyp_types:
 402                 if header.find(marker, 8, 12) == 8:
 403                     return mime
 404 
 405             return ''
 406 
 407 
 408         def guess_mime(header: bytes, fallback: str) -> str:
 409             # no bytes, no match
 410             if len(header) == 0:
 411                 return fallback
 412 
 413             # check the MPEG-4-like formats, the RIFF formats, and AIFF audio
 414             for f in (match_ftyp, match_riff, match_form):
 415                 m = f(header)
 416                 if m != '':
 417                     return m
 418 
 419             # maybe it's a bitmap picture, which usually has 40 on 15th byte
 420             if header.startswith(b'BM') and header.find(b'\x28', 8, 16) == 14:
 421                 return 'image/x-bmp'
 422 
 423             # check general lookup-table
 424             if header[0] in hdr_dispatch:
 425                 for maybe in hdr_dispatch[header[0]]:
 426                     if exact_match(header, maybe[0]):
 427                         return maybe[1]
 428 
 429             # try HTML, SVG, and even generic XML
 430             if header.find(b'<', 0, 8) >= 0:
 431                 for marker, mime in xmlish_heuristics:
 432                     if header.find(marker, 0, 64) >= 0:
 433                         return mime
 434 
 435             # try some common cases for JSON
 436             for pattern in json_heuristics:
 437                 if pattern.match(header):
 438                     return 'application/json'
 439 
 440             # nothing matched
 441             return fallback
 442 
 443         return guess_mime(header, 'application/octet-stream')
 444 
 445 
 446 def slurp_file(name):
 447     try:
 448         with open(name, 'r') as inp:
 449             return inp.read()
 450     except UnicodeDecodeError:
 451         with open(name, 'rb') as inp:
 452             return inp.read()
 453     except KeyboardInterrupt as e:
 454         raise e
 455     except Exception as e:
 456         return e
 457 
 458 
 459 def view(title, content):
 460     # save memory by clearing the variable holding the slurped string: when
 461     # input is big enough (say hundreds of MBs), the difference is noticeable
 462     def free_mem(s):
 463         nonlocal content
 464         content = ''
 465         return s
 466 
 467     # keep original stdin as /dev/fd/3
 468     dup2(0, 3)
 469 
 470     # make TUI work even when contents came from the standard input
 471     with open('/dev/tty', 'rb') as inp:
 472         dup2(inp.fileno(), 0)
 473 
 474     screen = initscr()
 475     savetty()
 476     noecho()
 477     cbreak()
 478     screen.keypad(True)
 479     curs_set(0)
 480     set_escdelay(10)
 481 
 482     def stop():
 483         resetty()
 484         endwin()
 485         # restore original stdin
 486         dup2(3, 0)
 487 
 488     try:
 489         tv = TextViewerTUI(screen)
 490         tv.title = title
 491         if title != '<stdin>':
 492             tv.file_name = title
 493         tv.side_step = 4
 494         tv.handlers['KEY_F(1)'] = lambda: show_help(screen)
 495         tv.run(free_mem(content))
 496     except KeyboardInterrupt as e:
 497         stop()
 498         raise e
 499     except Exception as e:
 500         stop()
 501         raise e
 502     stop()
 503 
 504 
 505 def catl(name):
 506     if name == '-':
 507         for line in stdin:
 508             print(line, end='')
 509         return
 510 
 511     with open(name, 'r') as inp:
 512         for line in inp:
 513             print(line, end='')
 514 
 515 
 516 def view_file(name):
 517     try:
 518         if not stdout.isatty():
 519             catl(name)
 520             return 0
 521 
 522         if name == '-':
 523             view('<stdin>', stdin.read())
 524         else:
 525             view(name, slurp_file(name))
 526         return 0
 527     except KeyboardInterrupt:
 528         return 1
 529     except Exception as e:
 530         # raise e
 531         print(str(e), file=stderr)
 532         return 1
 533 
 534 
 535 def show_help(screen):
 536     h = TextViewerTUI(screen, ('KEY_F(10)', 'KEY_F(12)', '\x1b', 'KEY_F(1)'))
 537     h.title = 'Help for `vt`'
 538     return h.run(info.strip()) != '\x1b'
 539 
 540 
 541 args = argv[1:]
 542 if len(args) > 0 and args[0] in ('-h', '--h', '-help', '--help'):
 543     print(info.strip())
 544     exit(0)
 545 
 546 if len(args) > 0 and args[0] == '--':
 547     args = args[1:]
 548 
 549 if len(args) > 1:
 550     print('multiple files: can only view 1 file at a time', file=stderr)
 551     exit(1)
 552 
 553 name = args[0] if len(args) == 1 else '-'
 554 exit(view_file(name))