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