1#!/usr/bin/env python3 2 3#**************************************************************************** 4# calcdlg.py, the main dialog view 5# 6# rpCalc, an RPN calculator 7# Copyright (C) 2017, Douglas W. Bell 8# 9# This is free software; you can redistribute it and/or modify it under the 10# terms of the GNU General Public License, either Version 2 or any later 11# version. This program is distributed in the hope that it will be useful, 12# but WITTHOUT ANY WARRANTY. See the included LICENSE file for details. 13#***************************************************************************** 14 15import sys 16import os.path 17from PyQt5.QtCore import (QPoint, QTimer, Qt) 18from PyQt5.QtGui import (QColor, QPalette) 19from PyQt5.QtWidgets import (QApplication, QDialog, QFrame, QGridLayout, 20 QHBoxLayout, QLCDNumber, QLabel, QMenu, 21 QMessageBox, QSizePolicy, QVBoxLayout, QWidget, 22 qApp) 23try: 24 from __main__ import __version__, __author__, helpFilePath, iconPath 25except ImportError: 26 __version__ = __author__ = '??' 27 helpFilePath = None 28 iconPath = None 29from calccore import CalcCore, Mode 30from calclcd import Lcd, LcdBox 31from calcbutton import CalcButton 32import extradisplay 33import altbasedialog 34import optiondlg 35import icondict 36import helpview 37 38 39class CalcDlg(QWidget): 40 """Main dialog for calculator program. 41 """ 42 def __init__(self, parent=None): 43 QWidget.__init__(self, parent) 44 self.calc = CalcCore() 45 self.setWindowTitle('rpCalc') 46 modPath = os.path.abspath(sys.path[0]) 47 if modPath.endswith('.zip') or modPath.endswith('.exe'): 48 modPath = os.path.dirname(modPath) # for py2exe/cx_freeze 49 iconPathList = [iconPath, os.path.join(modPath, 'icons/'), 50 os.path.join(modPath, '../icons')] 51 self.icons = icondict.IconDict() 52 self.icons.addIconPath(filter(None, iconPathList)) 53 self.icons.addIconPath([path for path in iconPathList if path]) 54 try: 55 QApplication.setWindowIcon(self.icons['calc_lg']) 56 except KeyError: 57 pass 58 self.setFocusPolicy(Qt.StrongFocus) 59 self.helpView = None 60 self.extraView = None 61 self.regView = None 62 self.histView = None 63 self.memView = None 64 self.altBaseView = None 65 self.optDlg = None 66 self.popupMenu = QMenu(self) 67 self.popupMenu.addAction('Registers on &LCD', self.toggleReg) 68 self.popupMenu.addSeparator() 69 self.popupMenu.addAction('Show &Register List', self.viewReg) 70 self.popupMenu.addAction('Show &History List', self.viewHist) 71 self.popupMenu.addAction('Show &Memory List', self.viewMem) 72 self.popupMenu.addSeparator() 73 self.popupMenu.addAction('Show Other &Bases', self.viewAltBases) 74 self.popupMenu.addSeparator() 75 self.popupMenu.addAction('Show Help &File', self.help) 76 self.popupMenu.addAction('&About rpCalc', self.about) 77 self.popupMenu.addSeparator() 78 self.popupMenu.addAction('&Quit', self.close) 79 topLay = QVBoxLayout(self) 80 self.setLayout(topLay) 81 topLay.setSpacing(4) 82 topLay.setContentsMargins(6, 6, 6, 6) 83 lcdBox = LcdBox() 84 topLay.addWidget(lcdBox) 85 lcdLay = QGridLayout(lcdBox) 86 lcdLay.setColumnStretch(1, 1) 87 lcdLay.setRowStretch(3, 1) 88 self.extraLabels = [QLabel(' T:',), QLabel(' Z:',), 89 QLabel(' Y:',)] 90 for i in range(3): 91 lcdLay.addWidget(self.extraLabels[i], i, 0, Qt.AlignLeft) 92 self.extraLcds = [Lcd(1.5, 13), Lcd(1.5, 13), Lcd(1.5, 13)] 93 lcdLay.addWidget(self.extraLcds[2], 0, 1, Qt.AlignRight) 94 lcdLay.addWidget(self.extraLcds[1], 1, 1, Qt.AlignRight) 95 lcdLay.addWidget(self.extraLcds[0], 2, 1, Qt.AlignRight) 96 if not self.calc.option.boolData('ViewRegisters'): 97 for w in self.extraLabels + self.extraLcds: 98 w.hide() 99 self.lcd = Lcd(2.0, 13) 100 lcdLay.addWidget(self.lcd, 3, 0, 1, 2, Qt.AlignRight) 101 self.setLcdHighlight() 102 self.updateLcd() 103 self.updateColors() 104 105 self.cmdLay = QGridLayout() 106 topLay.addLayout(self.cmdLay) 107 self.cmdDict = {} 108 self.addCmdButton('x^2', 0, 0) 109 self.addCmdButton('sqRT', 0, 1) 110 self.addCmdButton('y^X', 0, 2) 111 self.addCmdButton('xRT', 0, 3) 112 self.addCmdButton('RCIP', 0, 4) 113 self.addCmdButton('SIN', 1, 0) 114 self.addCmdButton('COS', 1, 1) 115 self.addCmdButton('TAN', 1, 2) 116 self.addCmdButton('LN', 1, 3) 117 self.addCmdButton('e^X', 1, 4) 118 self.addCmdButton('ASIN', 2, 0) 119 self.addCmdButton('ACOS', 2, 1) 120 self.addCmdButton('ATAN', 2, 2) 121 self.addCmdButton('LOG', 2, 3) 122 self.addCmdButton('tn^X', 2, 4) 123 self.addCmdButton('STO', 3, 0) 124 self.addCmdButton('RCL', 3, 1) 125 self.addCmdButton('R<', 3, 2) 126 self.addCmdButton('R>', 3, 3) 127 self.addCmdButton('x<>y', 3, 4) 128 self.addCmdButton('SHOW', 4, 0) 129 self.addCmdButton('CLR', 4, 1) 130 self.addCmdButton('PLCS', 4, 2) 131 self.addCmdButton('SCI', 4, 3) 132 self.addCmdButton('DEG', 4, 4) 133 self.addCmdButton('EXIT', 5, 0) 134 self.addCmdButton('Pi', 5, 1) 135 self.addCmdButton('EXP', 5, 2) 136 self.addCmdButton('CHS', 5, 3) 137 self.addCmdButton('<-', 5, 4) 138 139 self.mainLay = QGridLayout() 140 topLay.addLayout(self.mainLay) 141 self.mainDict = {} 142 self.addMainButton(0, 'OPT', 0, 0) 143 self.addMainButton(Qt.Key_Slash, '/', 0, 1) 144 self.addMainButton(Qt.Key_Asterisk, '*', 0, 2) 145 self.addMainButton(Qt.Key_Minus, '-', 0, 3) 146 self.addMainButton(Qt.Key_7, '7', 1, 0) 147 self.addMainButton(Qt.Key_8, '8', 1, 1) 148 self.addMainButton(Qt.Key_9, '9', 1, 2) 149 self.addMainButton(Qt.Key_Plus, '+', 1, 3, 1, 0) 150 self.addMainButton(Qt.Key_4, '4', 2, 0) 151 self.addMainButton(Qt.Key_5, '5', 2, 1) 152 self.addMainButton(Qt.Key_6, '6', 2, 2) 153 self.addMainButton(Qt.Key_1, '1', 3, 0) 154 self.addMainButton(Qt.Key_2, '2', 3, 1) 155 self.addMainButton(Qt.Key_3, '3', 3, 2) 156 self.addMainButton(Qt.Key_Enter, 'ENT', 3, 3, 1, 0) 157 self.addMainButton(Qt.Key_0, '0', 4, 0, 0, 1) 158 self.addMainButton(Qt.Key_Period, '.', 4, 2) 159 160 self.mainDict[Qt.Key_Return] = \ 161 self.mainDict[Qt.Key_Enter] 162 # added for european keyboards: 163 self.mainDict[Qt.Key_Comma] = \ 164 self.mainDict[Qt.Key_Period] 165 self.cmdDict['ENT'] = self.mainDict[Qt.Key_Enter] 166 self.cmdDict['OPT'] = self.mainDict[0] 167 168 self.entryStr = '' 169 self.showMode = False 170 171 statusBox = QFrame() 172 statusBox.setFrameStyle(QFrame.Panel | QFrame.Sunken) 173 statusBox.setSizePolicy(QSizePolicy(QSizePolicy.Preferred, 174 QSizePolicy.Preferred)) 175 topLay.addWidget(statusBox) 176 statusLay = QHBoxLayout(statusBox) 177 self.entryLabel = QLabel(statusBox) 178 statusLay.addWidget(self.entryLabel) 179 statusLay.setContentsMargins(1, 1, 1, 1) 180 self.statusLabel = QLabel(statusBox) 181 self.statusLabel.setAlignment(Qt.AlignRight) 182 statusLay.addWidget(self.statusLabel) 183 184 if self.calc.option.boolData('ExtraViewStartup'): 185 self.viewReg() 186 if self.calc.option.boolData('AltBaseStartup'): 187 self.viewAltBases() 188 189 xSize = self.calc.option.intData('MainDlgXSize', 0, 10000) 190 ySize = self.calc.option.intData('MainDlgYSize', 0, 10000) 191 if xSize and ySize: 192 self.resize(xSize, ySize) 193 self.move(self.calc.option.intData('MainDlgXPos', 0, 10000), 194 self.calc.option.intData('MainDlgYPos', 0, 10000)) 195 196 self.updateEntryLabel('rpCalc Version {0}'.format(__version__)) 197 QTimer.singleShot(5000, self.updateEntryLabel) 198 199 def updateEntryLabel(self, subsText=''): 200 """Set entry & status label text, use entryStr or subsText, options. 201 """ 202 numFormat = self.calc.option.boolData('ForceSciNotation') and 'sci' \ 203 or 'fix' 204 decPlcs = self.calc.option.intData('NumDecimalPlaces', 0, 9) 205 angle = self.calc.option.strData('AngleUnit') 206 self.statusLabel.setText('{0} {1} {2}'.format(numFormat, decPlcs, 207 angle)) 208 self.entryLabel.setText(subsText or '> {0}'.format(self.entryStr)) 209 210 def setOptions(self): 211 """Starts option dialog, called by option key. 212 """ 213 oldViewReg = self.calc.option.boolData('ViewRegisters') 214 self.optDlg = optiondlg.OptionDlg(self.calc.option, self) 215 self.optDlg.startGroupBox('Startup', 8) 216 optiondlg.OptionDlgBool(self.optDlg, 'SaveStacks', 217 'Save previous entries') 218 optiondlg.OptionDlgBool(self.optDlg, 'ExtraViewStartup', 219 'Auto open extra data view') 220 optiondlg.OptionDlgBool(self.optDlg, 'AltBaseStartup', 221 'Auto open alternate base view') 222 self.optDlg.startGroupBox('Display', 8) 223 optiondlg.OptionDlgInt(self.optDlg, 'NumDecimalPlaces', 224 'Number of decimal places', 0, 9) 225 optiondlg.OptionDlgBool(self.optDlg, 'ThousandsSeparator', 226 'Separate thousands with spaces') 227 optiondlg.OptionDlgBool(self.optDlg, 'ForceSciNotation', 228 'Always show exponent') 229 optiondlg.OptionDlgBool(self.optDlg, 'UseEngNotation', 230 'Use engineering notation') 231 optiondlg.OptionDlgBool(self.optDlg, 'TrimExponents', 232 'Hide exponent leading zeros') 233 optiondlg.OptionDlgBool(self.optDlg, 'ViewRegisters', 234 'View Registers on LCD') 235 optiondlg.OptionDlgBool(self.optDlg, 'HideLcdHighlight', 236 'Hide LCD highlight') 237 self.optDlg.startNewColumn() 238 optiondlg.OptionDlgRadio(self.optDlg, 'AngleUnit', 'Angular Units', 239 [('deg', 'Degrees'), ('rad', 'Radians')]) 240 self.optDlg.startGroupBox('Alternate Bases') 241 optiondlg.OptionDlgInt(self.optDlg, 'AltBaseBits', 'Size limit', 242 CalcCore.minNumBits, CalcCore.maxNumBits, 243 True, 4, False, ' bits') 244 optiondlg.OptionDlgBool(self.optDlg, 'UseTwosComplement', 245 'Use two\'s complement\nnegative numbers') 246 self.optDlg.startGroupBox('Extra Views',) 247 optiondlg.OptionDlgPush(self.optDlg, 'View Extra Data', self.viewExtra) 248 optiondlg.OptionDlgPush(self.optDlg, 'View Other Bases', 249 self.viewAltBases) 250 optiondlg.OptionDlgPush(self.optDlg, 'View Help file', self.help) 251 optiondlg.OptionDlgInt(self.optDlg, 'MaxHistLength', 252 'Saved history steps', CalcCore.minMaxHist, 253 CalcCore.maxMaxHist, True, 10) 254 if self.optDlg.exec_() == QDialog.Accepted: 255 self.calc.option.writeChanges() 256 newViewReg = self.calc.option.boolData('ViewRegisters') 257 if newViewReg != oldViewReg: 258 if newViewReg: 259 for w in self.extraLabels + self.extraLcds: 260 w.show() 261 else: 262 for w in self.extraLabels + self.extraLcds: 263 w.hide() 264 qApp.processEvents() 265 self.adjustSize() 266 if self.altBaseView: 267 self.altBaseView.updateOptions() 268 self.setLcdHighlight() 269 self.calc.updateXStr() 270 self.optDlg = None 271 272 def setLcdHighlight(self): 273 """Set lcd highlight based on option. 274 """ 275 opt = self.calc.option.boolData('HideLcdHighlight') and \ 276 QLCDNumber.Flat or QLCDNumber.Filled 277 self.lcd.setSegmentStyle(opt) 278 for lcd in self.extraLcds: 279 lcd.setSegmentStyle(opt) 280 281 def updateColors(self): 282 """Adjust the colors to the current option settings. 283 """ 284 if self.calc.option.boolData('UseDefaultColors'): 285 return 286 pal = QApplication.palette() 287 background = QColor(self.calc.option.intData('BackgroundR', 288 0, 255), 289 self.calc.option.intData('BackgroundG', 290 0, 255), 291 self.calc.option.intData('BackgroundB', 292 0, 255)) 293 foreground = QColor(self.calc.option.intData('ForegroundR', 294 0, 255), 295 self.calc.option.intData('ForegroundG', 296 0, 255), 297 self.calc.option.intData('ForegroundB', 298 0, 255)) 299 pal.setColor(QPalette.Base, background) 300 pal.setColor(QPalette.Text, foreground) 301 QApplication.setPalette(pal) 302 303 def viewExtra(self, defaultTab=0): 304 """Show extra data view. 305 """ 306 if self.optDlg: 307 self.optDlg.reject() # unfortunately necessary? 308 if not self.extraView: 309 self.extraView = extradisplay.ExtraDisplay(self) 310 self.extraView.tabUpdate(defaultTab) 311 self.extraView.tab.setCurrentIndex(defaultTab) 312 self.extraView.show() 313 314 def viewReg(self): 315 """Show extra data view with register tab open. 316 """ 317 self.viewExtra(0) 318 319 def viewHist(self): 320 """Show extra data view with history tab open. 321 """ 322 self.viewExtra(1) 323 324 def viewMem(self): 325 """Show extra data view with memory tab open. 326 """ 327 self.viewExtra(2) 328 329 def updateExtra(self): 330 """Update current extra and alt base views. 331 """ 332 if self.extraView and self.extraView.isVisible(): 333 self.extraView.updateData() 334 if self.altBaseView: 335 self.altBaseView.updateData() 336 337 def toggleReg(self): 338 """Toggle register display on LCD. 339 """ 340 viewReg = not self.calc.option.boolData('ViewRegisters') 341 self.calc.option.changeData('ViewRegisters', 342 viewReg and 'yes' or 'no', 1) 343 if viewReg: 344 for w in self.extraLabels + self.extraLcds: 345 w.show() 346 else: 347 for w in self.extraLabels + self.extraLcds: 348 w.hide() 349 self.adjustSize() 350 self.calc.updateXStr() 351 352 def viewAltBases(self): 353 """Show alternate base view. 354 """ 355 if self.optDlg: 356 self.optDlg.reject() # unfortunately necessary? 357 if not self.altBaseView: 358 self.altBaseView = altbasedialog.AltBaseDialog(self) 359 self.altBaseView.updateData() 360 self.altBaseView.show() 361 362 def findHelpFile(self): 363 """Return the path to the help file. 364 """ 365 modPath = os.path.abspath(sys.path[0]) 366 if modPath.endswith('.zip') or modPath.endswith('.exe'): 367 modPath = os.path.dirname(modPath) # for py2exe/cx_freeze 368 pathList = [helpFilePath, os.path.join(modPath, '../doc/'), 369 modPath, 'doc/'] 370 for path in pathList: 371 if path: 372 try: 373 fullPath = os.path.join(path, 'README.html') 374 with open(fullPath, 'r', encoding='utf-8') as f: 375 pass 376 return fullPath 377 except IOError: 378 pass 379 return '' 380 381 def help(self): 382 """View the ReadMe file. 383 """ 384 if self.optDlg: 385 self.optDlg.reject() # unfortunately necessary? 386 if not self.helpView: 387 path = self.findHelpFile() 388 if not path: 389 QMessageBox.warning(self, 'rpCalc', 390 'Read Me file not found') 391 return 392 self.helpView = helpview.HelpView(path, 'rpCalc README File', 393 self.icons, self) 394 self.helpView.show() 395 396 def about(self): 397 """About this program. 398 """ 399 QMessageBox.about(self, 'rpCalc', 400 'rpCalc, Version {0}\n by {1}'. 401 format(__version__, __author__)) 402 403 def addCmdButton(self, text, row, col): 404 """Adds a CalcButton for command functions. 405 """ 406 button = CalcButton(text) 407 self.cmdDict[text.upper()] = button 408 self.cmdLay.addWidget(button, row, col) 409 button.activated.connect(self.issueCmd) 410 411 def addMainButton(self, key, text, row, col, extraRow=0, extraCol=0): 412 """Adds a CalcButton for number and 4-function keys. 413 """ 414 button = CalcButton(text) 415 self.mainDict[key] = button 416 self.mainLay.addWidget(button, row, col, 1+extraRow, 1+extraCol) 417 button.activated.connect(self.issueCmd) 418 419 def updateLcd(self): 420 """Sets display back to CalcCore string. 421 """ 422 numDigits = int(self.calc.option.numData('NumDecimalPlaces', 0, 9)) + 9 423 if self.calc.option.boolData('ThousandsSeparator') or \ 424 self.calc.option.boolData('UseEngNotation'): 425 numDigits += 2 426 self.lcd.setDisplay(self.calc.xStr, numDigits) 427 if self.calc.option.boolData('ViewRegisters'): 428 nums = [self.calc.formatNum(num) for num in self.calc.stack[1:]] 429 for num, lcd in zip(nums, self.extraLcds): 430 lcd.setDisplay(num, numDigits) 431 self.updateExtra() 432 433 def issueCmd(self, text): 434 """Sends command text to CalcCore - connected to button signals. 435 """ 436 mode = self.calc.flag 437 text = str(text).upper() 438 if text == 'OPT': 439 self.setOptions() 440 elif text == 'SHOW': 441 if not self.showMode: 442 valueStr = self.calc.sciFormatX(11).replace('e', ' E', 1) 443 self.lcd.setNumDigits(19) 444 self.lcd.display(valueStr) 445 self.showMode = True 446 return 447 elif text == 'EXIT': 448 self.close() 449 return 450 else: 451 self.calc.cmd(text) 452 if text in ('SCI', 'DEG', 'OPT') or mode == Mode.decPlcMode: 453 self.updateEntryLabel() 454 self.showMode = False 455 self.updateLcd() 456 457 def textEntry(self, ch): 458 """Searches for button match from text entry. 459 """ 460 if not ch: 461 return False 462 if ord(ch) == 8: # backspace key 463 self.entryStr = self.entryStr[:-1] 464 elif ord(ch) == 27: # escape key 465 self.entryStr = '' 466 elif ch == '\t': # tab key 467 cmds = [key for key in self.cmdDict.keys() if 468 key.startswith(self.entryStr.upper())] 469 if len(cmds) == 1: 470 button = self.cmdDict[cmds[0]] 471 button.clickEvent() 472 button.tmpDown(300) 473 self.entryStr = '' 474 else: 475 QApplication.beep() 476 elif ch == ':' and not self.entryStr: 477 self.entryStr = ':' # optional command prefix 478 else: 479 newStr = (self.entryStr + ch).upper() 480 if newStr == ':Q': # vim-like shortcut 481 newStr = 'EXIT' 482 button = self.cmdDict.get(newStr.lstrip(':')) 483 if button: 484 button.clickEvent() 485 button.tmpDown(300) 486 self.entryStr = '' 487 else: 488 if [key for key in self.cmdDict.keys() if 489 key.startswith(newStr.lstrip(':'))]: 490 self.entryStr += ch 491 else: 492 QApplication.beep() 493 return False 494 self.updateEntryLabel() 495 return True 496 497 def keyPressEvent(self, keyEvent): 498 """Event handler for keys - checks for numbers and typed commands. 499 """ 500 button = self.mainDict.get(keyEvent.key()) 501 if not self.entryStr and button: 502 button.clickEvent() 503 button.setDown(True) 504 return 505 letter = str(keyEvent.text()).upper() 506 if keyEvent.modifiers() == Qt.AltModifier: 507 if self.altBaseView and self.altBaseView.isVisible(): 508 if letter in ('X', 'O', 'B', 'D'): 509 self.altBaseView.setCodedBase(letter, False) 510 elif letter == 'V': 511 self.altBaseView.copyValue() 512 elif letter == 'C': 513 self.altBaseView.close() 514 elif not self.entryStr and self.calc.base == 16 and \ 515 'A' <= letter <= 'F': 516 self.issueCmd(keyEvent.text()) 517 elif self.altBaseView and self.altBaseView.isVisible() and \ 518 (self.calc.xStr == ' 0' or \ 519 (self.calc.stack[0] == 0.0 and self.calc.base != 10)) and \ 520 self.calc.flag == Mode.entryMode and \ 521 letter in ('X', 'O', 'B', 'D'): 522 self.altBaseView.setCodedBase(letter, True) 523 elif not self.entryStr and keyEvent.key() == Qt.Key_Backspace: 524 button = self.cmdDict['<-'] 525 button.clickEvent() 526 button.tmpDown(300) 527 elif not self.entryStr and keyEvent.key() == Qt.Key_Escape: 528 self.popupMenu.popup(self.mapToGlobal(QPoint(0, 0))) 529 elif not self.textEntry(str(keyEvent.text())): 530 QWidget.keyPressEvent(self, keyEvent) 531 532 def keyReleaseEvent(self, keyEvent): 533 """Event handler for keys - sets button back to raised position. 534 """ 535 button = self.mainDict.get(keyEvent.key()) 536 if not self.entryStr and button: 537 button.setDown(False) 538 539 def closeEvent(self, event): 540 """Saves the stack prior to closing. 541 """ 542 self.calc.saveStack() 543 self.calc.option.changeData('MainDlgXSize', self.width(), True) 544 self.calc.option.changeData('MainDlgYSize', self.height(), True) 545 self.calc.option.changeData('MainDlgXPos', self.x(), True) 546 self.calc.option.changeData('MainDlgYPos', self.y(), True) 547 if self.extraView: 548 self.calc.option.changeData('ExtraViewXSize', 549 self.extraView.width(), True) 550 self.calc.option.changeData('ExtraViewYSize', 551 self.extraView.height(), True) 552 self.calc.option.changeData('ExtraViewXPos', 553 self.extraView.x(), True) 554 self.calc.option.changeData('ExtraViewYPos', 555 self.extraView.y(), True) 556 if self.altBaseView: 557 self.calc.option.changeData('AltBaseXPos', 558 self.altBaseView.x(), True) 559 self.calc.option.changeData('AltBaseYPos', 560 self.altBaseView.y(), True) 561 self.calc.option.writeChanges() 562 QWidget.closeEvent(self, event) 563