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