File: pyca.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 import math
  34 import random
  35 import statistics
  36 
  37 from math import *
  38 from random import *
  39 from statistics import *
  40 
  41 
  42 info = '''
  43 pyca [options...]
  44 
  45 
  46 PYthon CAlculator is an interactive calculator, a so-called TUI (terminal
  47 user interface).
  48 
  49 
  50     Escape     Quit this app
  51     F1         Toggle help-message screen; the Escape key also quits it
  52     F10        Quit this app
  53     F12        Quit this app
  54 
  55     Left       Scroll left, when any lines are wider than the screen
  56     Right      Scroll right, when any lines are wider than the screen
  57 
  58     Home       Go to the first line
  59     End        Go to the end, showing the last lines
  60     Up         Scroll 1 line up
  61     Down       Scroll 1 line down
  62     Page Up    Scroll 1 screen up
  63     Page Down  Scroll 1 screen down
  64 
  65 
  66 All (optional) leading options start with either single or double-dash:
  67 
  68     -h, -help    show this help message
  69 '''
  70 
  71 
  72 class TextViewerTUI:
  73     '''
  74     This is a scrollable viewer for plain-text content. After initializing it
  75     with a TUI screen value, you can configure various fields, before running
  76     it by calling method `run`:
  77         - title, which is shown at the top in reverse-style
  78         - tab_stop, which controls how tabs are turned into spaces
  79         - side_step, which controls the speed of lateral side-scrolling
  80         - handlers, which has all ncurses key-bindings for the viewer
  81     '''
  82 
  83     def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')):
  84         'Optional argument controls which ncurses keys quit the viewer.'
  85 
  86         self.title = ''
  87         self.tab_stop = 4
  88         self.side_step = 1
  89         self.handlers = {
  90             'KEY_RESIZE': lambda: self._on_resize(),
  91             'KEY_UP': lambda: self._on_up(),
  92             'KEY_DOWN': lambda: self._on_down(),
  93             'KEY_NPAGE': lambda: self._on_page_down(),
  94             'KEY_PPAGE': lambda: self._on_page_up(),
  95             'KEY_HOME': lambda: self._on_home(),
  96             'KEY_END': lambda: self._on_end(),
  97             'KEY_LEFT': lambda: self._on_left(),
  98             'KEY_RIGHT': lambda: self._on_right(),
  99         }
 100         if quit_set:
 101             for k in quit_set:
 102                 self.handlers[k] = None
 103 
 104         self._screen = screen
 105         self._inner_width = 0
 106         self._inner_height = 0
 107         self._max_line_width = 0
 108         self._top = 0
 109         self._left = 0
 110         self._max_top = 0
 111         self._max_left = 0
 112         self._lines = tuple()
 113 
 114     def run(self, content):
 115         'Interactively view/browse the string/strings given.'
 116 
 117         if isinstance(content, BaseException):
 118             self._on_resize()
 119             self._show_error(content)
 120             self._screen.getkey()
 121             return
 122 
 123         ts = self.tab_stop
 124         if isinstance(content, str):
 125             self._lines = tuple(l.expandtabs(ts) for l in content.splitlines())
 126         else:
 127             self._lines = tuple(l.expandtabs(ts) for l in content)
 128         content = '' # try to deallocate a few MBs when viewing big files
 129 
 130         if len(self._lines) == 0:
 131             self._max_line_width = 0
 132         else:
 133             self._max_line_width = max(len(l) for l in self._lines)
 134         self._on_resize()
 135 
 136         iw = self._inner_width
 137         ih = self._inner_height
 138 
 139         if iw < 10 or ih < 10:
 140             return
 141 
 142         while True:
 143             self._redraw()
 144             k = self._screen.getkey()
 145             if self.handlers and (k in self.handlers):
 146                 h = self.handlers[k]
 147                 if (h is None) or (h() is False):
 148                     self._lines = tuple()
 149                     return k
 150 
 151     def _fit_string(self, s):
 152         maxlen = max(self._inner_width, 0)
 153         return s if len(s) <= maxlen else s[:maxlen]
 154 
 155     def _redraw(self):
 156         title = self._fit_string(self.title)
 157         lines = self._lines
 158         screen = self._screen
 159         iw = self._inner_width
 160         ih = self._inner_height
 161 
 162         if iw < 10 or ih < 10:
 163             return
 164 
 165         screen.erase()
 166 
 167         if title:
 168             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 169             msg = 'F1: help | F10, F12, Escape: quit'
 170             screen.addstr(0, iw - 16 - len(msg), msg, A_REVERSE)
 171 
 172         from math import ceil, log10
 173 
 174         at_bottom = len(self._lines) - self._top <= ih
 175         w = int(ceil(log10(len(lines)))) if len(lines) > 0 else 1
 176         if at_bottom:
 177             msg = '(empty)'
 178             if len(lines) > 0:
 179                 msg = f'END ({self._top + 1:>{w},} / {len(lines):,})'
 180         else:
 181             msg = f'({self._top + 1:>{w},} / {len(lines):,})'
 182         screen.addstr(0, iw - len(msg), self._fit_string(msg), A_REVERSE)
 183 
 184         from itertools import islice
 185 
 186         for i, l in enumerate(islice(lines, self._top, self._top + ih)):
 187             if self._left > 0:
 188                 l = l[self._left:]
 189             try:
 190                 screen.addnstr(i + 1, 0, l, iw)
 191             except KeyboardInterrupt as e:
 192                 raise e
 193             except Exception:
 194                 # some utf-8 files have lines which upset func addstr
 195                 screen.addnstr(i + 1, 0, '?' * len(l), iw)
 196 
 197         # show up/down arrows
 198         if self._top > 0:
 199             self._screen.addstr(1, iw - 1, '')
 200         if self._top < self._max_top:
 201             self._screen.addstr(ih, iw - 1, '')
 202 
 203         screen.refresh()
 204 
 205     def _show_error(self, err):
 206         title = self._fit_string(self.title)
 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         if title:
 216             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 217         screen.addstr(2, 0, self._fit_string(str(err)), A_REVERSE)
 218         screen.refresh()
 219 
 220     def _on_resize(self):
 221         height, width = self._screen.getmaxyx()
 222         self._inner_width = width - 1
 223         self._inner_height = height - 1
 224         self._max_top = max(len(self._lines) - self._inner_height, 0)
 225         ss = self.side_step
 226         self._max_left = self._max_line_width - self._inner_width - 1 + ss
 227         self._max_left = max(self._max_left, 0)
 228 
 229     def _on_up(self):
 230         self._top = max(self._top - 1, 0)
 231 
 232     def _on_down(self):
 233         self._top = min(self._top + 1, self._max_top)
 234 
 235     def _on_page_up(self):
 236         self._top = max(self._top - self._inner_height, 0)
 237 
 238     def _on_page_down(self):
 239         self._top = min(self._top + self._inner_height, self._max_top)
 240 
 241     def _on_home(self):
 242         self._top = 0
 243 
 244     def _on_end(self):
 245         self._top = self._max_top
 246 
 247     def _on_left(self):
 248         self._left = max(self._left - self.side_step, 0)
 249 
 250     def _on_right(self):
 251         self._left = min(self._left + self.side_step, self._max_left)
 252 
 253 
 254 class CalculatorTUI:
 255     '''
 256     This is a scrollable viewer for calculated single-line expressions. After
 257     initializing it with a TUI screen value, you can configure various fields,
 258     before running it by calling method `run`:
 259         - title, which is shown at the top in reverse-style
 260         - side_step, which controls the speed of lateral side-scrolling
 261         - handlers, which has all ncurses key-bindings
 262     '''
 263 
 264     def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')):
 265         'Optional argument controls which ncurses keys quit the viewer.'
 266 
 267         self.title = ''
 268         self.code = ''
 269         self.side_step = 1
 270         self.handlers = {
 271             'KEY_RESIZE': lambda: self._on_resize(),
 272             'KEY_UP': lambda: self._on_up(),
 273             'KEY_DOWN': lambda: self._on_down(),
 274             'KEY_NPAGE': lambda: self._on_page_down(),
 275             'KEY_PPAGE': lambda: self._on_page_up(),
 276             'KEY_HOME': lambda: self._on_home(),
 277             'KEY_END': lambda: self._on_end(),
 278             'KEY_LEFT': lambda: self._on_left(),
 279             'KEY_RIGHT': lambda: self._on_right(),
 280             'KEY_BACKSPACE': lambda: self._on_backspace(),
 281             '\n': lambda: self._on_enter(),
 282         }
 283         if quit_set:
 284             for k in quit_set:
 285                 self.handlers[k] = None
 286 
 287         self._screen = screen
 288         self._inner_width = 0
 289         self._inner_height = 0
 290         self._top = 0
 291         self._left = 0
 292         self._max_top = 0
 293         self._max_left = 0
 294         self._history = []
 295 
 296     def run(self):
 297         self._on_resize()
 298 
 299         iw = self._inner_width
 300         ih = self._inner_height
 301 
 302         if iw < 10 or ih < 10:
 303             return
 304 
 305         while True:
 306             self._redraw()
 307             k = self._screen.getkey()
 308             if k == '\x1b' and self.code:
 309                 self.code = ''
 310                 continue
 311             if self.handlers and (k in self.handlers):
 312                 h = self.handlers[k]
 313                 if (h is None) or (h() is False):
 314                     return k
 315                 continue
 316             self.code += k
 317 
 318     def _fit_string(self, s):
 319         maxlen = max(self._inner_width, 0)
 320         return s if len(s) <= maxlen else s[:maxlen]
 321 
 322     def _redraw(self):
 323         title = self._fit_string(self.title)
 324         screen = self._screen
 325         iw = self._inner_width
 326         ih = self._inner_height
 327 
 328         if iw < 10 or ih < 10:
 329             return
 330 
 331         screen.erase()
 332 
 333         if title:
 334             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 335             msg = 'F1: help | F10, F12, Escape: quit'
 336             screen.addstr(0, iw - len(msg), msg, A_REVERSE)
 337         try:
 338             screen.addnstr(1, 0, self.code, iw)
 339             screen.addnstr(1, len(self.code), ' ', iw, A_REVERSE)
 340         except KeyboardInterrupt as e:
 341             raise e
 342         except Exception:
 343             # some utf-8 files have lines which upset func addstr
 344             screen.addnstr(1, 0, '?' * 10, iw)
 345 
 346         from itertools import islice
 347 
 348         try:
 349             if len(self.code.strip()) > 0:
 350                 screen.addnstr(2, 0, str(eval(self.code)), iw)
 351             else:
 352                 msg = 'type a python expression (F1 shows a help screen)'
 353                 screen.addnstr(2, 0, msg, iw)
 354         except KeyboardInterrupt as e:
 355             raise e
 356         except Exception as e:
 357             # some utf-8 files have lines which upset func addstr
 358             screen.addnstr(2, 0, str(e), iw)
 359 
 360         start = max(0, len(self._history) - (ih - 5))
 361         for i, s in enumerate(islice(self._history, start, None), 3):
 362             screen.addnstr(i, 0, s, iw)
 363 
 364         screen.refresh()
 365 
 366     def _show_error(self, err):
 367         title = self._fit_string(self.title)
 368         screen = self._screen
 369         iw = self._inner_width
 370         ih = self._inner_height
 371 
 372         if iw < 10 or ih < 10:
 373             return
 374 
 375         screen.erase()
 376         if title:
 377             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 378         screen.addstr(2, 0, self._fit_string(str(err)), A_REVERSE)
 379         screen.refresh()
 380 
 381     def _on_resize(self):
 382         height, width = self._screen.getmaxyx()
 383         self._inner_width = width - 1
 384         self._inner_height = height - 1
 385         ss = self.side_step
 386         self._max_left = max(self._max_left, 0)
 387 
 388     def _on_up(self):
 389         self._top = max(self._top - 1, 0)
 390 
 391     def _on_down(self):
 392         self._top = min(self._top + 1, self._max_top)
 393 
 394     def _on_page_up(self):
 395         self._top = max(self._top - self._inner_height, 0)
 396 
 397     def _on_page_down(self):
 398         self._top = min(self._top + self._inner_height, self._max_top)
 399 
 400     def _on_home(self):
 401         self._top = 0
 402 
 403     def _on_end(self):
 404         self._top = self._max_top
 405 
 406     def _on_left(self):
 407         self._left = max(self._left - self.side_step, 0)
 408 
 409     def _on_right(self):
 410         self._left = min(self._left + self.side_step, self._max_left)
 411 
 412     def _on_backspace(self):
 413         if len(self.code) > 0:
 414             self.code = self.code[:-1]
 415 
 416     def _on_enter(self):
 417         if len(self._history) > 0:
 418             s = self._history[-1]
 419             if s.startswith(f'{self.code} -> '):
 420                 return
 421 
 422         try:
 423             self._history.append(f'{self.code} -> {str(eval(self.code))}')
 424             m = self._inner_height - 3
 425             if len(self._history) >= m:
 426                 self._history = self._history[-m:]
 427         except:
 428             pass
 429 
 430 
 431 def slurp_file(name):
 432     try:
 433         with open(name, 'r') as inp:
 434             return inp.read()
 435     except KeyboardInterrupt as e:
 436         raise e
 437     except Exception as e:
 438         return e
 439 
 440 
 441 def run(title):
 442     # keep original stdin as /dev/fd/3
 443     dup2(0, 3)
 444 
 445     # make TUI work even when contents came from the standard input
 446     with open('/dev/tty', 'rb') as inp:
 447         dup2(inp.fileno(), 0)
 448 
 449     screen = initscr()
 450     savetty()
 451     noecho()
 452     cbreak()
 453     screen.keypad(True)
 454     curs_set(0)
 455     set_escdelay(10)
 456 
 457     def stop():
 458         resetty()
 459         endwin()
 460         # restore original stdin
 461         dup2(3, 0)
 462 
 463     try:
 464         c = CalculatorTUI(screen)
 465         c.title = title
 466         c.side_step = 4
 467         c.handlers['KEY_F(1)'] = lambda: show_help(screen)
 468         c.run()
 469     except KeyboardInterrupt as e:
 470         stop()
 471         return 0
 472     except Exception as e:
 473         stop()
 474         raise e
 475         print(str(e), file=stderr)
 476         return 1
 477     stop()
 478     return 0
 479 
 480 
 481 def show_help(screen):
 482     h = TextViewerTUI(screen, ('KEY_F(10)', 'KEY_F(12)', '\x1b', 'KEY_F(1)'))
 483     h.title = 'Help for `pyca` (PYthon CAlculator)'
 484     return h.run(info.strip()) != '\x1b'
 485 
 486 
 487 args = argv[1:]
 488 if len(args) > 0 and args[0] in ('-h', '--h', '-help', '--help'):
 489     print(info.strip())
 490     exit(0)
 491 
 492 if len(args) > 1 and args[0] == '--':
 493     args = args[1:]
 494 
 495 exit(run('PYthon CALCulator (pyca)'))