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