File: pygadgets-products/unzipped/_PyCalc/Calculator/calculator.py

#!/usr/local/bin/python
"""
################################################################################
PyCalc 4.0: a Python/tkinter calculator program and GUI component.

[SA] 4.0, Sep-2017: Standalone release of PyCalc, PyClock, PyPhoto, PyToe.
Copyright 2017 M.Lutz, from book "Programming Python, 4th Edition".
License: provided freely, but with no warranties of any kind.

Evaluates expressions as they are entered per operator precedence, 
catches button clicks and keyboard keys for expression entry. 

4.0 (2017, standalone rlease [SA])
-automatic comma separators in main display
-enhanced help display
-Mac OS port
-new configs model: file or args, more options
-extra operators via new 'more' button frame
-x! factorial, -x, int(x), and 'e' extra ops
-x^y power operator with same precedence as * and /
-statistics module functions import

3.0+ (2010, PP4E, version number retained):
-port to run under Python 3.X (only)
-drop 'L' keypress (the long type is now dead in earnest)

3.0 changes (2005, PP3E):
-use 'readonly' entry state, not 'disabled', else field is greyed
 out (fix for 2.3 Tkinter change);
-avoid extended display precision for floats by using str(), instead
 of `x`/repr() (fix for Python change);
-apply font to input field to make it larger;
-use justify=right for input field so it displays on right, not left;
-add 'E+' and 'E-' buttons (and 'E' keypress) for float exponents;
 'E' keypress must generally be followed digits, not + or - optr key;
-remove 'L' button (but still allow 'L' keypress): superfluous now,
 because Python auto converts up if too big ('L' forced this in past);
-use smaller font size overall;
-auto scroll to the end in the history window

2.0 (1999, PP2E) evaluated expression parts as entered, added integrated 
command-line popups, a recent calculations history display popup, fonts
and colors configuration, help and about popups, preimported math/random
constants, and more;

1.0 (1996, PP1E) was a very simplistic calculator GUI that just built up 
Pyton expressions and passed them to eval() as a whole;

done 4.0: add a commas-insertion mode (see StringVarCommas);
done 4.0: allow '**' as an operator key (added as "^" key)
todo: allow '+' and 'J' inputs for complex Numbers
todo: use new decimal type for fixed precision floats; as is, can 
use 'cmd' popup windows to input and evaluate things like complex, but 
can't be input via main window; 
caveat: PyCalc's precision, accuracy, and some of its behaviour, is 
currently bound by result of the built-in str() call;
################################################################################
"""

import sys
from tkinter import *                                            # widgets, consts
from PP4E.Gui.Tools.guimixin import GuiMixin                     # quit method
from PP4E.Gui.Tools.widgets import label, entry, button, frame   # widget builders

debugme = True
def trace(*args):
    if debugme: print(args)

# [SA] port to Mac OS
RunningOnMac = sys.platform.startswith('darwin')     # all Mac OS (X)

# [SA]: set window icons on Windows and Linux
from windowicons import trySetWindowIcon


################################################################################
# The main class - handles user interface.
# An extended Frame, on new Toplevel, or embedded in another container widget.
################################################################################

class CalcGui(GuiMixin, Frame):

    Operators = "+-*/^="                             # button lists, [SA] +^ 
    Operands  = ["abcd", "0123", "4567", "89()"]     # customizable here

    # [SA] additions, from book's subclasses
    Extras = [(' x! ',  'factorial(%s)'),
              ('-x ',   '-(%s)'),
              ('x^2',   '(%s)**2'),
              ('1/x',   '1.0/(%s)'),
              ('sqrt',  'sqrt(%s)'),
              ('int',   'int(%s)')]

    def __init__(self, parent=None, configs=object()):
        Frame.__init__(self, parent)                 # None=default Tk root
        self.pack(expand=YES, fill=BOTH)             # all parts expandable
        self.eval = Evaluator()                      # embed a stack handler
        self.text = StringVarCommas()                # extended linked variable
        self.text.set("0")
        self.erase = 1                               # clear "0" text next
        self.makeWidgets(configs)                    # build the GUI itself
        if not parent or not isinstance(parent, Frame):
            self.master.title('PyCalc 4.0')          # title iff owns window
            self.master.iconname("PyCalc")           # ditto for key bindings
            self.master.bind('<KeyPress>', self.onKeyboard)
            self.entry.config(state='readonly')      # 3.0: not 'disabled'=grey
        else:
            self.entry.config(state='normal')
            self.entry.focus()
        self.root = parent

    def makeWidgets(self, configs): 
        """
        build the GUI (N frames plus text-entry), register events:
        """
        font = configs.Font                          # font, color configurable
        bg, fg = configs.BgColor, configs.FgColor

        self.entry = entry(self, TOP, self.text)
        self.entry.config(font=font)                 # 3.0: make display larger
        self.entry.config(justify=RIGHT)             # 3.0: on right, not left
       #self.entry.pack(expand=NO, fill=X)           # [SA] grow vertically too

        for row in self.Operands:
            frm = frame(self, TOP)
            for char in row:
                if RunningOnMac:
                    # [SA] emulate colored buttons
                    l = label(frm, LEFT, char,
                           fg=fg, bg=bg, font=font)
                    l.bind('<Button-1>', lambda e, op=char: self.onOperand(op))
                else:
                    button(frm, LEFT, char, lambda op=char: self.onOperand(op),
                           fg=fg, bg=bg, font=font)

        frm = frame(self, TOP)
        for char in self.Operators:
            if RunningOnMac:
                # [SA] emulate colored buttons
                l = label(frm, LEFT, char,
                       fg=bg, bg=fg, font=font)
                l.bind('<Button-1>', lambda e, op=char: self.onOperator(op))

            else:
                button(frm, LEFT, char, lambda op=char: self.onOperator(op),
                       fg=bg, bg=fg, font=font)

        frm = frame(self, TOP)
        button(frm, LEFT, 'dot ', lambda: self.onOperand('.'))
        button(frm, LEFT, ' E+ ', lambda: self.text.set(self.text.get()+'E+'))
        button(frm, LEFT, ' E- ', lambda: self.text.set(self.text.get()+'E-'))
        button(frm, LEFT, 'cmd ', lambda: self.onMakeCmdline(configs))
        button(frm, LEFT, 'help', self.help)
        button(frm, LEFT, 'quit', self.quit)       # from guimixin

        frm = frame(self, BOTTOM)
        button(frm, LEFT, 'eval ', self.onEval)
        button(frm, LEFT, 'hist ', lambda: self.onHist(configs))
        button(frm, LEFT, 'more ', self.onMore)
        button(frm, LEFT, 'clear', self.onClear)

        # [SA] make+hide additions
        morefrm = frame(self, TOP)
        for (lab, expr) in self.Extras:
            button(morefrm, LEFT, lab, (lambda expr=expr: self.onExtra(expr)))
        button(morefrm, LEFT, 'pi', lambda: self.onLiteral('pi'))
        button(morefrm, LEFT, 'e',  lambda: self.onLiteral('e'))
        morefrm.pack_forget()
        self.morefrm, self.moretgl = morefrm, 0

        # [SA] bind Delete for erase on Macs (\b isn't auto)
        if RunningOnMac:
            class Dummy: char = '\b'
            self.master.bind('<BackSpace>', lambda e: self.onKeyboard(Dummy))

    def onMore(self):
        """
        [SA] show/hide extra-row additions
        """
        self.moretgl += 1
        if self.moretgl % 2:
            self.morefrm.pack(expand=YES, fill=BOTH)
        else:
            self.morefrm.pack_forget()

    def onClear(self):
        """
        clear calculator state 
        """
        self.eval.clear()
        self.text.set('0')
        self.erase = 1

    def onEval(self):
        """
        run eval operation: eval all still-open exprs
        """
        self.eval.shiftOpnd(self.text.get())     # last or only opnd
        self.eval.closeall()                     # apply all optrs left
        self.text.set(self.eval.popOpnd())       # need to pop: optr next?
        self.erase = 1

    def onOperand(self, char):
        """
        handle an operand button or keypress
        """
        if char == '(':
            self.eval.open()
            self.text.set('(')                      # clear text next
            self.erase = 1
        elif char == ')':
            self.eval.shiftOpnd(self.text.get())    # last or only nested opnd
            self.eval.close()                       # pop here too: optr next?
            self.text.set(self.eval.popOpnd())
            self.erase = 1
        else:
            if self.erase:
                self.text.set(char)                     # clears last value
            else:
                self.text.set(self.text.get() + char)   # else append to opnd
            self.erase = 0

    def onOperator(self, char):
        """
        handle an operator button or keypress
        """
        self.eval.shiftOpnd(self.text.get())    # push opnd on left
        self.eval.shiftOptr(char)               # eval exprs to left?
        self.text.set(self.eval.topOpnd())      # push optr, show opnd|result
        self.erase = 1                          # erased on next opnd|'('

    def onExtra(self, expr):
        """
        [SA] addition: run extra-row expr with value substitution
        """
        try:
            self.text.set(self.eval.runstring(expr % self.text.get()))
        except:
            self.text.set('ERROR')

    def onLiteral(self, literal):
        """
        [SA] addition: run extra-row literal expr
        """
        self.text.set(self.eval.runstring(literal))  # e.g., 'pi', 'e'

    def onMakeCmdline(self, configs):
        """
        new non-modal top-level window for arbitrary Python code
        """
        new = Toplevel()                            # new top-level window
        new.title('PyCalc Command Line')
        trySetWindowIcon(new, 'icons', 'pygadgets') # [SA] for win+lin

        frm = frame(new, TOP)                       # only the Entry expands
        label(frm, LEFT, '>>>').pack(expand=NO)
        var = StringVar()                           # [SA] no commas here
        ent = entry(frm, LEFT, var, width=40)
        ent.config(font=configs.Font)               # [SA] now configurable 

        onButton = (lambda: self.onCmdline(var, ent))
        onReturn = (lambda event: self.onCmdline(var, ent))
        button(frm, RIGHT, 'Run', onButton).pack(expand=NO)
        ent.bind('<Return>', onReturn)
        var.set(self.text.get())
        ent.focus()   # [SA] on this entry and window

    def onCmdline(self, var, ent): 
        """
        evaluate cmdline pop-up input
        """
        try:
            value = self.eval.runstring(var.get())
            var.set('OKAY')
            if value != None:                 # run in eval namespace dict
                self.text.set(value)          # expression or statement
                self.erase = 1
                var.set('OKAY => '+ value)
        except:                               # result in calc field
            var.set('ERROR')                  # status in pop-up field
        ent.icursor(END)                      # insert point after text
        ent.select_range(0, END)              # select msg so next key deletes

    def onKeyboard(self, event):
        """
        on keyboard press event, pretend button was pressed,
        or handle extras - backspace (not delete), ?=help;
        """
        pressed = event.char 
        if pressed != '':
            if pressed in self.Operators:
                self.onOperator(pressed)
            else:
                for row in self.Operands:
                    if pressed in row:
                        self.onOperand(pressed)
                        break
                else:                                          # 4E: drop 'Ll'
                    if pressed == '.':
                        self.onOperand(pressed)                # can start opnd
                    if pressed in 'Ee':  # 2e10, no +/-
                        self.text.set(self.text.get()+pressed) # can't: no erase
                    elif pressed == '\r':
                        self.onEval()                          # enter key=eval
                    elif pressed == ' ':
                        self.onClear()                         # spacebar=clear
                    elif pressed == '\b':
                        self.text.set(self.text.get()[:-1])    # backspace
                    elif pressed == '?':                       # [SA] +Mac delete
                        self.help()

    def onHist(self, configs):
        """
        show recent calcs log popup
        """
        from tkinter.scrolledtext import ScrolledText     # or PP4E.Gui.Tour

        new = Toplevel()                                  # make new window
        new.title('PyCalc History')
        trySetWindowIcon(new, 'icons', 'pygadgets')       # [SA] for win+lin

        # new window goes away on ok press or enter key
        ok = Button(new, text=' OK ', command=new.destroy)
        ok.pack(pady=1, side=BOTTOM)                      # pack first=clip last
        new.bind("<Return>", (lambda event: new.destroy()))

        text = ScrolledText(new, bg='beige')              # add Text + scrollbar
        bg, font = configs.HistBgColor, configs.HistFont  # [SA] now configurable
        text.config(bg=bg, font=font)
        text.insert('0.0', self.eval.getHist())           # get Evaluator text
        text.see(END)                                     # 3.0: scroll to end
        text.pack(expand=YES, fill=BOTH)

        # go modal until window destroyed
        ok.focus_set()                      # make new window modal:
        new.grab_set()                      # get keyboard focus, grab app
        new.wait_window()                   # don't return till new.destroy

    def help(self):
        """
        [SA] fully redesigned, and helpmessage replaces self.infobox();
        called for 'help' button click, '?' keyboard press, Mac menus;
        """
        from helpmessage import showhelp
        showhelp(self.root, 'PyCalc', self.HelpText, forcetext=False,
                 setwinicon=lambda win:
                        trySetWindowIcon(win, 'icons', 'pygadgets'))
        #if self.root: self.root.focus_force()   # now done in helpmessage

    HelpText = ('PyCalc 4.0\n'
                '\n'
                'A Python/tkinter calculator GUI.\n'
                'For Mac OS, Windows, and Linux.\n'
                'From the book Programming Python.\n'
                'Author and © M. Lutz 1996-2017.\n'
                '\n'
                'Use button clicks or keyboard presses to '
                'input numbers and operators, or type '
                'Python expression code in a "cmd" popup.\n'
                '\n'
                'Keyboard usage: spacebar="clear", enter="eval", '
                '.="dot", ?="help", backspace or delete=erase 1 character.  '
                'Comma separators are inserted automatically for '
                'display as numbers are entered and shown.\n'
                '\n'
                'Tips:\n'
                '▶ "=" assigns variables '
                '(e.g., ab=99, ab+1)\n'
                '▶ "eval" evaluates pending expressions\n'
                '▶ "more" shows/hides extra keys\n'
                '▶ "hist" displays recent calculations\n'
                '▶ "^" is x^y power (Python\'s "**")\n'
                '▶ "int" and "* 1." convert to int and float\n'
                '\n'
                'The "cmd" dialog supports entry of additional '
                'ops, including all functions in Python\'s math, '
                'random, statistics, and builtins modules.  E.g., '
                'sin(x), log(x, b), random(), mean([]), max(x, y), set().\n'
                '\n'
                'Version history (see source for changes):\n'
                '● 4.0: Sep 2017, standalone release\n'
                '● 3.1: May 2010, Programming Python 4E\n'
                '● 3.0 2005 3E, 2.0 1999 2E, 1.0 1996 1E\n'
                '\n'
                'For downloads and more apps, visit:\n'
                'http://learning-python.com/programs.html'
               )


################################################################################
# The expression evaluator class.
# Embedded in and used by a CalcGui instance, to perform calculations.
################################################################################

class Evaluator:
    def __init__(self):
        self.names = {}                              # a names-space for my vars
        self.opnd, self.optr = [], []                # two empty stacks
        self.hist = []                               # my prev calcs history log

        # preimport math modules into calc's namespace for "cmd"
        # namespaces are disjoint: set(dir(math)) & set(dir(other))
        
        self.runstring("from math import *")         # sin(x), log(x, b), pi, e
        self.runstring("from random import *")       # plus builtins: max(), abs()
        try:
            # [SA] new in py 3.4, ignore is absent
            self.runstring("from statistics import *")   # mean(), median(), etc
        except:
            print('Note: PyCalc cannot load statistics module in your Python;')
            print('upgrade to Python 3.4 or later to use its tools in PyCalc.')

    def clear(self):
        self.opnd, self.optr = [], []           # leave names intact
        if len(self.hist) > 64:                 # don't let hist get too big
            self.hist = ['clear']
        else:
            self.hist.append('--clear--')

    def popOpnd(self):
        value = self.opnd[-1]                   # pop/return top|last opnd
        self.opnd[-1:] = []                     # to display and shift next
        return value                            # or x.pop(), or del x[-1]

    def topOpnd(self):
        return self.opnd[-1]                    # top operand (end of list)

    def open(self):
        self.optr.append('(')                   # treat '(' like an operator

    def close(self):                            # on ')' pop downto highest '('
        self.shiftOptr(')')                     # ok if empty: stays empty
        self.optr[-2:] = []                     # pop, or added again by optr

    def closeall(self):
        while self.optr:                        # force rest on 'eval'
            self.reduce()                       # last may be a var name
        try:
            self.opnd[0] = self.runstring(self.opnd[0])
        except:
            self.opnd[0] = '*ERROR*'            # pop else added again next:

    afterMe = {'^': ['+', '-', '(', '='],       # [SA] add power operator
               '*': ['+', '-', '(', '='],       # class member
               '/': ['+', '-', '(', '='],       # optrs to not pop for key
               '+': ['(', '='],                 # if prior optr is this: push
               '-': ['(', '='],                 # else: pop/eval prior optr
               ')': ['(', '='],                 # all left-associative as is
               '=': ['('] }

    def shiftOpnd(self, newopnd):               # push opnd at optr, ')', eval
        self.opnd.append(newopnd)

    def shiftOptr(self, newoptr):               # apply ops with <= priority
        while (self.optr and
               self.optr[-1] not in self.afterMe[newoptr]):
            self.reduce()
        self.optr.append(newoptr)               # push this op above result
                                                # optrs assume next opnd erases
    def reduce(self):
        trace(self.optr, self.opnd)
        try:                                    # collapse the top expr
            operator       = self.optr[-1]      # pop top optr (at end)
            [left, right]  = self.opnd[-2:]     # pop top 2 opnds (at end)
            self.optr[-1:] = []                 # delete slice in-place
            self.opnd[-2:] = []
            result = self.runstring(left + operator + right)
            if result == None:
                result = left                   # assignment? key var name
            self.opnd.append(result)            # push result string back
        except:
            self.opnd.append('*ERROR*')         # stack/number/name error

    def runstring(self, rawcode):
        code = rawcode.replace('^', '**')                     # [SA] xlate power
        try:                                                  # 3.0: not `x`/repr
            result = str(eval(code, self.names, self.names))  # try expr: string
            self.hist.append(rawcode + ' => ' + result)       # add to hist log
        except:
            exec(code, self.names, self.names)                # try stmt: None
            self.hist.append(rawcode)
            result = None
        return result

    def getHist(self):
        return '\n'.join(self.hist)


################################################################################
# [SA] StringVar wrapper class.
# Used to manage thousands-separator commas display with minimal changes.
################################################################################

class StringVarCommas(StringVar):
    """
    in the main number-display area (only: not cmd): auto insert commas as 
    numbers are entered, and remove them when fetched for the evaluator;
    this also removes commas on backspace erases by a get+set combination;
    literal comma presses are simply ignored (TBD: use them as a toggle?);

    some lex errors are caught here (e.g., '1E2.'), but others pass here 
    and fail later in the evaluator (e.g., '1..2', '..1', '1EE2', '.'), 
    due in part to split(x, 1): removing the 1 would make more fail here;
    """

    # extend StringVar interface

    def get(self):
        text = StringVar.get(self)         # get display text 
        return self.decommify(text)        # strip commas

    def set(self, text):
        text = self.commify(text)          # add commas
        StringVar.set(self, text)          # set display text


    # add text processing methods

    def decommify(self, text):
        return text.replace(',', '')

    def commify(self, text):
        text = self.decommify(text)
       #text = '{:,}'.format(num)          # requires an eval()

        # strip sign if added by -X key
        if text.startswith('-'):
            sign, text = '-' , text[1:]
        else:
            sign = ''

        # add commas to whole-number part
        try:
            if text.isdigit():                                  # all digits: 'xxx'
                return sign + '{:,}'.format(int(text))          # add ',' and sign 

            elif '.' in text:                                   # also for '.' or '..'
                whole, rest = text.split('.', 1)                # 'x.y' 'x.' '.y' 'x.yEz'
                if whole:                                       # nov17: '.any' => '.any'
                    whole = '{:,}'.format(int(whole))           # (whole or '0') adds '0'
                return sign + whole + '.' + rest                # covers '0.1e2', '.1e-2'

            elif 'E' in text.upper() and 'ERROR' not in text:
                whole, exp = text.upper().split('E', 1)         #  no '.' but whole base
                whole = '{:,}'.format(int(whole))               # 'xe+z', 'xe-z', 'xez'
                return sign + whole + 'E' + exp

            else:
                return text                                     # other: allow 'abcd'

        except:
            return 'ERROR'   # anything not recognized: avoid uncaught exception in GUI


################################################################################
# Main logic - when run standalone.
# Get optional configs via command-line args, make and start a CalcGui object.
################################################################################

if __name__ == '__main__':
    from getConfigs import getConfigs      # [SA] new common gadgets utility
    defaults = dict(InitialSize=None, 
                    BgColor='wheat', 
                    FgColor='black', 
                    Font=('courier', 14, 'bold'),  # or 'family...' str arg
                    HistBgColor='beige',
                    HistFont=None)
    configs = getConfigs('PyCalc', defaults)       # load from file or args

    root = Tk()                                    # non-default top-level window
    if configs.InitialSize:
        root.geometry(configs.InitialSize)         # 'Wxh' size string
    trySetWindowIcon(root, 'icons', 'pygadgets')   # [SA] for win+lin
    calc = CalcGui(root, configs)                  # build gui on root

    if RunningOnMac:
        # Mac requires menus, deiconifies, focus

        # [SA] on Mac, customize app-wide automatic top-of-display menu
        from guimaker_pp4e import fixAppleMenuBar
        fixAppleMenuBar(window=root,
                        appname='PyCalc',
                        helpaction=calc.help,      # bound method (has self)
                        aboutaction=None,
                        quitaction=calc.quit)      # app-wide quit: ask

        # [SA] reopen auto on dock/app click and fix tk focus loss on deiconify
        def onReopen():
            root.lift()
            root.update()
            temp = Toplevel()
            temp.lower()
            temp.destroy()
        root.createcommand('::tk::mac::ReopenApplication', onReopen)

    root.mainloop()



[Home page] Books Code Blog Python Author Train Find ©M.Lutz