#!/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_REVERSE, ) from os import dup2 from sys import argv, stderr, stdin, stdout import math import random import statistics from math import * from random import * from statistics import * info = ''' pyca [options...] PYthon CAlculator is an interactive calculator, a so-called TUI (terminal user interface). 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 All (optional) leading options start with either single or double-dash: -h, -help show this help message ''' 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) msg = 'F1: help | F10, F12, Escape: quit' screen.addstr(0, iw - 16 - len(msg), msg, A_REVERSE) from math import ceil, log10 at_bottom = len(self._lines) - self._top <= ih w = int(ceil(log10(len(lines)))) if len(lines) > 0 else 1 if at_bottom: msg = '(empty)' if len(lines) > 0: msg = f'END ({self._top + 1:>{w},} / {len(lines):,})' else: msg = f'({self._top + 1:>{w},} / {len(lines):,})' screen.addstr(0, iw - len(msg), self._fit_string(msg), A_REVERSE) from itertools import islice 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 KeyboardInterrupt as e: raise e 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) class CalculatorTUI: ''' This is a scrollable viewer for calculated single-line expressions. 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 - side_step, which controls the speed of lateral side-scrolling - handlers, which has all ncurses key-bindings ''' 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.code = '' 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(), 'KEY_BACKSPACE': lambda: self._on_backspace(), '\n': lambda: self._on_enter(), } if quit_set: for k in quit_set: self.handlers[k] = None self._screen = screen self._inner_width = 0 self._inner_height = 0 self._top = 0 self._left = 0 self._max_top = 0 self._max_left = 0 self._history = [] def run(self): 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 k == '\x1b' and self.code: self.code = '' continue if self.handlers and (k in self.handlers): h = self.handlers[k] if (h is None) or (h() is False): return k continue self.code += 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) 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) msg = 'F1: help | F10, F12, Escape: quit' screen.addstr(0, iw - len(msg), msg, A_REVERSE) try: screen.addnstr(1, 0, self.code, iw) screen.addnstr(1, len(self.code), ' ', iw, A_REVERSE) except KeyboardInterrupt as e: raise e except Exception: # some utf-8 files have lines which upset func addstr screen.addnstr(1, 0, '?' * 10, iw) from itertools import islice try: if len(self.code.strip()) > 0: screen.addnstr(2, 0, str(eval(self.code)), iw) else: msg = 'type a python expression (F1 shows a help screen)' screen.addnstr(2, 0, msg, iw) except KeyboardInterrupt as e: raise e except Exception as e: # some utf-8 files have lines which upset func addstr screen.addnstr(2, 0, str(e), iw) start = max(0, len(self._history) - (ih - 5)) for i, s in enumerate(islice(self._history, start, None), 3): screen.addnstr(i, 0, s, iw) 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 ss = self.side_step 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 _on_backspace(self): if len(self.code) > 0: self.code = self.code[:-1] def _on_enter(self): if len(self._history) > 0: s = self._history[-1] if s.startswith(f'{self.code} -> '): return try: self._history.append(f'{self.code} -> {str(eval(self.code))}') m = self._inner_height - 3 if len(self._history) >= m: self._history = self._history[-m:] except: pass def slurp_file(name): try: with open(name, 'r') as inp: return inp.read() except KeyboardInterrupt as e: raise e except Exception as e: return e def run(title): # keep original stdin as /dev/fd/3 dup2(0, 3) # make TUI work even when contents came from the standard input with open('/dev/tty', 'rb') as inp: dup2(inp.fileno(), 0) screen = initscr() savetty() noecho() cbreak() screen.keypad(True) curs_set(0) set_escdelay(10) def stop(): resetty() endwin() # restore original stdin dup2(3, 0) try: c = CalculatorTUI(screen) c.title = title c.side_step = 4 c.handlers['KEY_F(1)'] = lambda: show_help(screen) c.run() except KeyboardInterrupt as e: stop() return 0 except Exception as e: stop() raise e print(str(e), file=stderr) return 1 stop() return 0 def show_help(screen): h = TextViewerTUI(screen, ('KEY_F(10)', 'KEY_F(12)', '\x1b', 'KEY_F(1)')) h.title = 'Help for `pyca` (PYthon CAlculator)' 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:] exit(run('PYthon CALCulator (pyca)'))