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