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