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