#!/usr/bin/python # The MIT License (MIT) # # Copyright (c) 2026 pacman64 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. from curses import \ cbreak, curs_set, endwin, initscr, noecho, resetty, savetty, \ set_escdelay, A_NORMAL, A_REVERSE from itertools import islice from os import dup2 from sys import argv, stderr, stdin info = ''' vt [options...] [file...] View Text is a (UTF-8) plain-text file viewer; when not given a filename, it reads text from the standard input, and only starts showing it when all reading is done. Escape Quit this app F1 Toggle help-message screen; the Escape key also quits it F10 Quit this app F12 Quit this app Left Scroll left, when any lines are wider than the screen Right Scroll right, when any lines are wider than the screen Home Go to the first line End Go to the end, showing the last lines Up Scroll 1 line up Down Scroll 1 line down Page Up Scroll 1 screen up Page Down Scroll 1 screen down The only options are one of the help options, which show this message and quit right away, without using the interactive mode: these are `-h`, `--h`, `-help`, and `--help`, without the quotes. ''' class SimpleTUI: ''' Manager to start/stop a no-color text user-interface (TUI), allowing for standard input/output to be used normally before method `start` is called and after method `stop` is called. After calling is method `start`, its field `screen` has the ncurses value for all the interactive input-output. You can also call method `wrapper` with a callback function accepting the `screen` ncurses value. ''' def __init__(self): self.screen = None def start(self, out_fd = -1, esc_delay = -1): ''' Start interactive-mode: the first optional argument should be more than 2, if given, since it would mess with stdio, which is precisely what it's meant to avoid doing. ''' if out_fd >= 0: # keep original stdout as /dev/fd/... dup2(1, out_fd) # separate live output from final (optional) result on stdout with open('/dev/tty', 'rb') as inp, open('/dev/tty', 'wb') as out: dup2(inp.fileno(), 0) dup2(out.fileno(), 1) self.screen = initscr() savetty() noecho() cbreak() self.screen.keypad(True) curs_set(0) if esc_delay >= 0: set_escdelay(esc_delay) def stop(self): 'Stop interactive-mode.' if self.screen: resetty() endwin() def wrapper(self, func, out_fd = -1, esc_delay = -1): 'Run a function accepting the screen value for all TUI interactions.' try: self.start(out_fd, esc_delay) func(self.screen) except KeyboardInterrupt: pass finally: self.stop() class TextViewerTUI: ''' This is a scrollable viewer for plain-text content. After initializing it with a TUI screen value, you can configure various fields, before running it by calling method `run`: - title, which is shown at the top in reverse-style - tab_stop, which controls how tabs are turned into spaces - side_step, which controls the speed of lateral side-scrolling - handlers, which has all ncurses key-bindings for the viewer ''' def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')): 'Optional argument controls which ncurses keys quit the viewer.' self.title = '' self.tab_stop = 4 self.side_step = 1 self.handlers = { 'KEY_RESIZE': lambda: self._on_resize(), 'KEY_UP': lambda: self._on_up(), 'KEY_DOWN': lambda: self._on_down(), 'KEY_NPAGE': lambda: self._on_page_down(), 'KEY_PPAGE': lambda: self._on_page_up(), 'KEY_HOME': lambda: self._on_home(), 'KEY_END': lambda: self._on_end(), 'KEY_LEFT': lambda: self._on_left(), 'KEY_RIGHT': lambda: self._on_right(), } if quit_set: for k in quit_set: self.handlers[k] = None self._screen = screen self._inner_width = 0 self._inner_height = 0 self._max_line_width = 0 self._top = 0 self._left = 0 self._max_top = 0 self._max_left = 0 self._lines = tuple() def run(self, content): 'Interactively view/browse the string/strings given.' if isinstance(content, BaseException): self._on_resize() self._show_error(content) self._screen.getkey() return ts = self.tab_stop if isinstance(content, str): self._lines = tuple(l.expandtabs(ts) for l in content.splitlines()) else: self._lines = tuple(l.expandtabs(ts) for l in content) content = '' # try to deallocate a few MBs when viewing big files if len(self._lines) == 0: self._max_line_width = 0 else: self._max_line_width = max(len(l) for l in self._lines) self._on_resize() iw = self._inner_width ih = self._inner_height if iw < 10 or ih < 10: return while True: self._redraw() k = self._screen.getkey() if self.handlers and (k in self.handlers): h = self.handlers[k] if (h is None) or (h() is False): self._lines = tuple() return k def _fit_string(self, s): maxlen = max(self._inner_width, 0) return s if len(s) <= maxlen else s[:maxlen] def _redraw(self): title = self._fit_string(self.title) lines = self._lines screen = self._screen iw = self._inner_width ih = self._inner_height if iw < 10 or ih < 10: return screen.erase() if title: screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE) at_bottom = len(self._lines) - self._top <= ih if at_bottom: msg = f'END ({len(lines):,})' if len(lines) > 0 else '(empty)' else: msg = f'({self._top + 1:,} / {len(lines):,})' screen.addstr(0, iw - len(msg), self._fit_string(msg), A_REVERSE) for i, l in enumerate(islice(lines, self._top, self._top + ih)): if self._left > 0: l = l[self._left:] try: screen.addnstr(i + 1, 0, l, iw) except Exception: # some utf-8 files have lines which upset func addstr screen.addnstr(i + 1, 0, '?' * len(l), iw) # show up/down arrows if self._top > 0: self._screen.addstr(1, iw - 1, '▲') if self._top < self._max_top: self._screen.addstr(ih, iw - 1, '▼') screen.refresh() def _show_error(self, err): title = self._fit_string(self.title) screen = self._screen iw = self._inner_width ih = self._inner_height if iw < 10 or ih < 10: return screen.erase() if title: screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE) screen.addstr(2, 0, self._fit_string(str(err)), A_REVERSE) screen.refresh() def _on_resize(self): height, width = self._screen.getmaxyx() self._inner_width = width - 1 self._inner_height = height - 1 self._max_top = max(len(self._lines) - self._inner_height, 0) ss = self.side_step self._max_left = self._max_line_width - self._inner_width - 1 + ss self._max_left = max(self._max_left, 0) def _on_up(self): self._top = max(self._top - 1, 0) def _on_down(self): self._top = min(self._top + 1, self._max_top) def _on_page_up(self): self._top = max(self._top - self._inner_height, 0) def _on_page_down(self): self._top = min(self._top + self._inner_height, self._max_top) def _on_home(self): self._top = 0 def _on_end(self): self._top = self._max_top def _on_left(self): self._left = max(self._left - self.side_step, 0) def _on_right(self): self._left = min(self._left + self.side_step, self._max_left) def slurp_file(name): try: with open(name, 'r') as inp: return inp.read() except Exception as e: return e def view(title, content): # save memory by clearing the variable holding the slurped string def free_mem(s): nonlocal content content = '' return s tui = SimpleTUI() tui.start(3, 10) tv = TextViewerTUI(tui.screen) tv.title = name tv.side_step = 4 tv.handlers['KEY_F(1)'] = lambda: show_help(tui) tv.run(free_mem(content)) tui.stop() def view_file(name): try: if name == '-': view('', stdin.read()) else: view(name, slurp_file(name)) return 0 except KeyboardInterrupt: return 1 except Exception as e: print(str(e), file=stderr) return 1 def show_help(screen): quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b', 'KEY_F(1)') h = TextViewerTUI(screen, quit_set) h.title = 'Help for `Tinker`' return h.run(info.strip()) != '\x1b' args = argv[1:] if len(args) > 0 and args[0] in ('-h', '--h', '-help', '--help'): print(info.strip()) exit(0) if len(args) > 1 and args[0] == '--': args = args[1:] if len(args) > 1: print('can only view 1 file', file=stderr) exit(1) name = args[0] if len(args) == 1 else '-' exit(view_file(name))