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