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). Besides showing previous formulas confirmed via the Enter
  48 key and their results, it also shows common unit-conversions of the latest
  49 result on the side, if the result is numeric.
  50 
  51 
  52     Enter      Add current formula into the history, if formula is valid
  53     Escape     Quit this app
  54     F1         Toggle help-message screen; the Escape key also quits it
  55     F2         Toggle automatic unit-conversions
  56     F10        Quit this app
  57     F12        Quit this app
  58 
  59 
  60 All (optional) leading options start with either single or double-dash:
  61 
  62     -h, -help    show this help message
  63 '''
  64 
  65 
  66 class TextViewerTUI:
  67     '''
  68     This is a scrollable viewer for plain-text content. After initializing it
  69     with a TUI screen value, you can configure various fields, before running
  70     it by calling method `run`:
  71         - title, which is shown at the top in reverse-style
  72         - tab_stop, which controls how tabs are turned into spaces
  73         - side_step, which controls the speed of lateral side-scrolling
  74         - handlers, which has all ncurses key-bindings for the viewer
  75     '''
  76 
  77     def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')):
  78         'Optional argument controls which ncurses keys quit the viewer.'
  79 
  80         self.title = ''
  81         self.tab_stop = 4
  82         self.side_step = 1
  83         self.handlers = {
  84             'KEY_RESIZE': lambda: self._on_resize(),
  85             'KEY_UP': lambda: self._on_up(),
  86             'KEY_DOWN': lambda: self._on_down(),
  87             'KEY_NPAGE': lambda: self._on_page_down(),
  88             'KEY_PPAGE': lambda: self._on_page_up(),
  89             'KEY_HOME': lambda: self._on_home(),
  90             'KEY_END': lambda: self._on_end(),
  91             'KEY_LEFT': lambda: self._on_left(),
  92             'KEY_RIGHT': lambda: self._on_right(),
  93         }
  94         if quit_set:
  95             for k in quit_set:
  96                 self.handlers[k] = None
  97 
  98         self._screen = screen
  99         self._inner_width = 0
 100         self._inner_height = 0
 101         self._max_line_width = 0
 102         self._top = 0
 103         self._left = 0
 104         self._max_top = 0
 105         self._max_left = 0
 106         self._lines = tuple()
 107 
 108     def run(self, content):
 109         'Interactively view/browse the string/strings given.'
 110 
 111         if isinstance(content, BaseException):
 112             self._on_resize()
 113             self._show_error(content)
 114             self._screen.getkey()
 115             return
 116 
 117         ts = self.tab_stop
 118         if isinstance(content, str):
 119             self._lines = tuple(l.expandtabs(ts) for l in content.splitlines())
 120         else:
 121             self._lines = tuple(l.expandtabs(ts) for l in content)
 122         content = '' # try to deallocate a few MBs when viewing big files
 123 
 124         if len(self._lines) == 0:
 125             self._max_line_width = 0
 126         else:
 127             self._max_line_width = max(len(l) for l in self._lines)
 128         self._on_resize()
 129 
 130         iw = self._inner_width
 131         ih = self._inner_height
 132 
 133         if iw < 10 or ih < 10:
 134             return
 135 
 136         while True:
 137             self._redraw()
 138             k = self._screen.getkey()
 139             if self.handlers and (k in self.handlers):
 140                 h = self.handlers[k]
 141                 if (h is None) or (h() is False):
 142                     self._lines = tuple()
 143                     return k
 144 
 145     def _fit_string(self, s):
 146         maxlen = max(self._inner_width, 0)
 147         return s if len(s) <= maxlen else s[:maxlen]
 148 
 149     def _redraw(self):
 150         title = self._fit_string(self.title)
 151         lines = self._lines
 152         screen = self._screen
 153         iw = self._inner_width
 154         ih = self._inner_height
 155 
 156         if iw < 10 or ih < 10:
 157             return
 158 
 159         screen.erase()
 160 
 161         if title:
 162             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 163 
 164         from math import ceil, log10
 165 
 166         at_bottom = len(self._lines) - self._top <= ih
 167         w = int(ceil(log10(len(lines)))) if len(lines) > 0 else 1
 168         if at_bottom:
 169             msg = '(empty)'
 170             if len(lines) > 0:
 171                 msg = f'END ({self._top + 1:>{w},} / {len(lines):,})'
 172         else:
 173             msg = f'({self._top + 1:>{w},} / {len(lines):,})'
 174         screen.addstr(0, iw - len(msg), self._fit_string(msg), A_REVERSE)
 175 
 176         from itertools import islice
 177 
 178         for i, l in enumerate(islice(lines, self._top, self._top + ih)):
 179             if self._left > 0:
 180                 l = l[self._left:]
 181             try:
 182                 screen.addnstr(i + 1, 0, l, iw)
 183             except KeyboardInterrupt as e:
 184                 raise e
 185             except Exception as _:
 186                 # some utf-8 strings upset func addstr
 187                 screen.addnstr(i + 1, 0, '?' * len(l), iw)
 188 
 189         # show up/down arrows
 190         if self._top > 0:
 191             self._screen.addstr(1, iw - 1, '▲')
 192         if self._top < self._max_top:
 193             self._screen.addstr(ih, iw - 1, '▼')
 194 
 195         screen.refresh()
 196 
 197     def _show_error(self, err):
 198         title = self._fit_string(self.title)
 199         screen = self._screen
 200         iw = self._inner_width
 201         ih = self._inner_height
 202 
 203         if iw < 10 or ih < 10:
 204             return
 205 
 206         screen.erase()
 207         if title:
 208             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 209         screen.addstr(2, 0, self._fit_string(str(err)), A_REVERSE)
 210         screen.refresh()
 211 
 212     def _on_resize(self):
 213         height, width = self._screen.getmaxyx()
 214         self._inner_width = width - 1
 215         self._inner_height = height - 1
 216         self._max_top = max(len(self._lines) - self._inner_height, 0)
 217         ss = self.side_step
 218         self._max_left = self._max_line_width - self._inner_width - 1 + ss
 219         self._max_left = max(self._max_left, 0)
 220 
 221     def _on_up(self):
 222         self._top = max(self._top - 1, 0)
 223 
 224     def _on_down(self):
 225         self._top = min(self._top + 1, self._max_top)
 226 
 227     def _on_page_up(self):
 228         self._top = max(self._top - self._inner_height, 0)
 229 
 230     def _on_page_down(self):
 231         self._top = min(self._top + self._inner_height, self._max_top)
 232 
 233     def _on_home(self):
 234         self._top = 0
 235 
 236     def _on_end(self):
 237         self._top = self._max_top
 238 
 239     def _on_left(self):
 240         self._left = max(self._left - self.side_step, 0)
 241 
 242     def _on_right(self):
 243         self._left = min(self._left + self.side_step, self._max_left)
 244 
 245 
 246 class CalculatorTUI:
 247     '''
 248     This is a scrollable viewer for calculated single-line expressions. After
 249     initializing it with a TUI screen value, you can configure various fields,
 250     before running it by calling method `run`:
 251         - title, which is shown at the top in reverse-style
 252         - side_step, which controls the speed of lateral side-scrolling
 253         - handlers, which has all ncurses key-bindings
 254     '''
 255 
 256     def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')):
 257         'Optional argument controls which ncurses keys quit the viewer.'
 258 
 259         self.title = ''
 260         self.code = ''
 261         self.autoconv = {}
 262         self.show_conversions = False
 263         self.side_step = 1
 264         self.handlers = {
 265             'KEY_RESIZE': lambda: self._on_resize(),
 266             'KEY_F(2)': lambda: self._on_autoconv(),
 267             'KEY_UP': lambda: self._on_up(),
 268             'KEY_DOWN': lambda: self._on_down(),
 269             'KEY_NPAGE': lambda: self._on_page_down(),
 270             'KEY_PPAGE': lambda: self._on_page_up(),
 271             'KEY_HOME': lambda: self._on_home(),
 272             'KEY_END': lambda: self._on_end(),
 273             'KEY_LEFT': lambda: self._on_left(),
 274             'KEY_RIGHT': lambda: self._on_right(),
 275             'KEY_BACKSPACE': lambda: self._on_backspace(),
 276             '\n': lambda: self._on_enter(),
 277         }
 278         if quit_set:
 279             for k in quit_set:
 280                 self.handlers[k] = None
 281 
 282         self._screen = screen
 283         self._inner_width = 0
 284         self._inner_height = 0
 285         self._top = 0
 286         self._left = 0
 287         self._max_top = 0
 288         self._max_left = 0
 289         self._history = []
 290 
 291     def run(self):
 292         if not self.autoconv:
 293             self.autoconv = {}
 294 
 295         self._on_resize()
 296 
 297         iw = self._inner_width
 298         ih = self._inner_height
 299 
 300         if iw < 10 or ih < 10:
 301             return
 302 
 303         while True:
 304             self._redraw()
 305             k = self._screen.getkey()
 306             if k == '\x1b' and self.code:
 307                 self.code = ''
 308                 continue
 309             if self.handlers and (k in self.handlers):
 310                 h = self.handlers[k]
 311                 if (h is None) or (h() is False):
 312                     return k
 313                 continue
 314             self.code += k
 315 
 316     def _fit_string(self, s):
 317         maxlen = max(self._inner_width, 0)
 318         return s if len(s) <= maxlen else s[:maxlen]
 319 
 320     def _redraw(self):
 321         title = self._fit_string(self.title)
 322         screen = self._screen
 323         iw = self._inner_width
 324         ih = self._inner_height
 325 
 326         if iw < 10 or ih < 10:
 327             return
 328 
 329         screen.erase()
 330 
 331         if title:
 332             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 333             s = 'F1: help | F10, F12: quit'
 334             screen.addstr(0, iw - 2 - len(s), s, A_REVERSE)
 335         try:
 336             screen.addnstr(1, 0, self.code, iw)
 337             screen.addnstr(1, len(self.code), ' ', iw, A_REVERSE)
 338         except KeyboardInterrupt as e:
 339             raise e
 340         except Exception as _:
 341             # some utf-8 strings upset func addstr
 342             screen.addnstr(1, 0, '?' * 10, iw)
 343 
 344         ok = len(self.code.strip()) > 0
 345 
 346         try:
 347             res = eval(self.code) if ok else None
 348         except KeyboardInterrupt as e:
 349             raise e
 350         except Exception as e:
 351             res = str(e)
 352 
 353         def conv(res, units, fn):
 354             src, to = units.split(' -> ')
 355             try:
 356                 s = '<too big>' if abs(res) > 1e20 else f'{fn(res):,.6f}'
 357             except KeyboardInterrupt as e:
 358                 raise e
 359             except Exception as _:
 360                 s = '<too big>'
 361             return f'{src} ~ {s} {to:<2}'
 362 
 363         if self.show_conversions and isinstance(res, (float, int)):
 364             for (i, (units, fn)) in enumerate(self.autoconv.items()):
 365                 s = conv(res, units, fn)
 366                 l = len(s)
 367 
 368                 try:
 369                     screen.addnstr(i + 2, iw - l, s, l)
 370                 except KeyboardInterrupt as e:
 371                     raise e
 372                 except Exception as _:
 373                     pass
 374 
 375         try:
 376             if ok:
 377                 if isinstance(res, float):
 378                     s = f'{res:,.6f}'
 379                 elif isinstance(res, int):
 380                     s = f'{res:,}'
 381                 else:
 382                     s = str(res)
 383                 screen.addnstr(2, 0, s, iw)
 384             else:
 385                 msg = 'type a python expression (F1 shows a help screen)'
 386                 screen.addnstr(2, 0, msg, iw)
 387         except KeyboardInterrupt as e:
 388             raise e
 389         except Exception as e:
 390             # some utf-8 strings upset func addstr
 391             screen.addnstr(2, 0, str(e), iw)
 392 
 393         from itertools import islice
 394 
 395         start = max(0, len(self._history) - (ih - 5))
 396         for i, s in enumerate(islice(self._history, start, None), 3):
 397             screen.addnstr(i + 1, 0, s, iw)
 398 
 399         screen.refresh()
 400 
 401     def _show_error(self, err):
 402         title = self._fit_string(self.title)
 403         screen = self._screen
 404         iw = self._inner_width
 405         ih = self._inner_height
 406 
 407         if iw < 10 or ih < 10:
 408             return
 409 
 410         screen.erase()
 411         if title:
 412             screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE)
 413         screen.addstr(2, 0, self._fit_string(str(err)), A_REVERSE)
 414         screen.refresh()
 415 
 416     def _on_resize(self):
 417         height, width = self._screen.getmaxyx()
 418         self._inner_width = width - 1
 419         self._inner_height = height - 1
 420         ss = self.side_step
 421         self._max_left = max(self._max_left, 0)
 422 
 423     def _on_autoconv(self):
 424         self.show_conversions = not self.show_conversions
 425 
 426     def _on_up(self):
 427         self._top = max(self._top - 1, 0)
 428 
 429     def _on_down(self):
 430         self._top = min(self._top + 1, self._max_top)
 431 
 432     def _on_page_up(self):
 433         self._top = max(self._top - self._inner_height, 0)
 434 
 435     def _on_page_down(self):
 436         self._top = min(self._top + self._inner_height, self._max_top)
 437 
 438     def _on_home(self):
 439         self._top = 0
 440 
 441     def _on_end(self):
 442         self._top = self._max_top
 443 
 444     def _on_left(self):
 445         self._left = max(self._left - self.side_step, 0)
 446 
 447     def _on_right(self):
 448         self._left = min(self._left + self.side_step, self._max_left)
 449 
 450     def _on_backspace(self):
 451         if len(self.code) > 0:
 452             self.code = self.code[:-1]
 453 
 454     def _on_enter(self):
 455         if len(self._history) > 0:
 456             s = self._history[-1]
 457             if s.startswith(f'{self.code} -> '):
 458                 return
 459 
 460         try:
 461             res = eval(self.code)
 462             if isinstance(res, float):
 463                 s = f'{res:,.6f}'
 464             elif isinstance(res, int):
 465                 s = f'{res:,}'
 466             else:
 467                 s = str(res)
 468             self._history.append(f'{self.code} -> {s}')
 469             m = self._inner_height - 3
 470             if len(self._history) >= m:
 471                 self._history = self._history[-m:]
 472         except KeyboardInterrupt as e:
 473             raise e
 474         except Exception as _:
 475             pass
 476 
 477 
 478 def run(title, autoconv = {}):
 479     # keep original stdin as /dev/fd/3
 480     dup2(0, 3)
 481 
 482     # make TUI work even when contents came from the standard input
 483     with open('/dev/tty', 'rb') as inp:
 484         dup2(inp.fileno(), 0)
 485 
 486     screen = initscr()
 487     savetty()
 488     noecho()
 489     cbreak()
 490     screen.keypad(True)
 491     curs_set(0)
 492     set_escdelay(10)
 493 
 494     def stop():
 495         resetty()
 496         endwin()
 497         # restore original stdin
 498         dup2(3, 0)
 499 
 500     try:
 501         c = CalculatorTUI(screen)
 502         c.title = title
 503         if autoconv:
 504             c.autoconv = autoconv
 505             c.show_conversions = True
 506         c.side_step = 4
 507         c.handlers['KEY_F(1)'] = lambda: show_help(screen)
 508         c.run()
 509     except KeyboardInterrupt as e:
 510         stop()
 511         return 0
 512     except Exception as e:
 513         stop()
 514         # raise e
 515         print(str(e), file=stderr)
 516         return 1
 517     stop()
 518     return 0
 519 
 520 
 521 def show_help(screen):
 522     h = TextViewerTUI(screen, ('KEY_F(10)', 'KEY_F(12)', '\x1b', 'KEY_F(1)'))
 523     h.title = 'Help for `pyca` (PYthon CAlculator)'
 524     return h.run(info.strip()) != '\x1b'
 525 
 526 
 527 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'):
 528     print(info.strip())
 529     exit(0)
 530 
 531 if len(argv) > 1:
 532     s = argv[1]
 533     if s != '-' and s != '--' and s.startswith('-'):
 534         print(f'pyca: unsupported option {s}', file=stderr)
 535         exit(1)
 536 
 537 args = argv[1:]
 538 if len(args) > 0 and args[0] == '--':
 539     args = args[1:]
 540 
 541 autoconv = {
 542     'in -> cm': lambda x: 2.54 * x,
 543     'yd -> m': lambda x: 1.093613 * x,
 544     'ft -> m': lambda x: 0.3048 * x,
 545     'mi -> km': lambda x: 1.609344 * x,
 546     'nmi -> km': lambda x: 1.852 * x,
 547     'gal -> L': lambda x: 0.2199692 * x,
 548     'lb -> kg': lambda x: 0.4535924 * x,
 549     'oz -> g': lambda x: 28.34952 * x,
 550     '°F -> °C': lambda x: (5.0 / 9.0) * x - 32.0 * (5.0 / 9.0),
 551 }
 552 
 553 exit(run('PYthon CAlculator (pyca)', autoconv))