I'm a beginner developer, and it's my very first project: a simple math operations calculator in Python 3 using Tkinter for GUI.
Obs: This code is an evolution of my previous code of the same project: initial code.
This calculator, in despite of only process the four basic math operations (add, subtraction, multiplication an division), tries to behave like a classic calculator about the inputs treatment.
Using examples as a classic calculator:
Press 2, so plus button, then 3, then equal. The result 5 will be shown. After this, keeping pressing equal will proceed the addition of 3 to the respective result.
When user press an operator key (+,-,* or /) after the first operand input, pressing a different operator will change the operator.
After the the operands input, pressing an operator key will lead to result and operator storage. (User press 2, press plus, press 3, then press minus. The result 5 will be shown and the minus operator will be stored).
I've followed the suggestions the best I could and refactored all the code.
The main problems that I tried to solve were:
- Using
eval
to process calculation.
It seemed so convenient using eval
at the beggining. I didn´t have to create any rules or data treatment. Also, the calculator could process big expressions including things like parenthesis.
But I was warned about the vulnerability of using eval
. Then I also realized that it is a pretty lazy solution.
So, I´ve managed to include methods for data treatment and processing the calculations. The application now behaves more like a conventional calculator and removed the capacity to calculate expressions at once.
- Too many modules:
The fact of having too many modules lead me to have problems with circular imports and incorrect import calls, as I didn´t know much about importing. After some study about it and following the suggestions gave to me, I made the code lean by using only two modules and using imports correctly.
- Code repetition:
The blocks for buttons creation and placement had too many lines. So I´ve followed some instructions to dinamically create and place them.
- Unecessary use of
self
:
As a begginer at Python and OOP, I was using self
at every variable inside the classes and was warned about self
on variables to be used only for a variable that will be changed later in the class outside of the method it is generated in. So, the new code removed self
s where wasn´t really necessary.
Finally, that´s the new code:
Module: main.py
import tkinter
import application
class TkinterWindow(tkinter.Tk):
def __init__(self):
super().__init__()
self.geometry('640x480')
def main():
window = TkinterWindow()
app = application.Calculator(window)
app.pack(fill='both', expand=1, padx=10, pady=10)
app.mainloop()
if __name__ == '__main__':
main()
Module: application.py
import tkinter
class Calculator(tkinter.Frame):
def __init__(self, parent):
super().__init__(parent)
self.rowconfigure(0, weight=1) # Row for display
self.rowconfigure(1, weight=1) # Row for buttons frame
self.columnconfigure(0, weight=1)
self.focus_set()
# Variables for calculations and process control
self.display_chars = tkinter.StringVar()
self.display_chars.set(0)
self.aggregator = []
self.operator = None
self.decimal_separator = False
self.total = 0
self.error = None
self.first_operator = 0
self.second_operator = 0
self.first_operator_status = False
self.new_entry = True
self.aggregator_status = 'Inactive'
self.place_frames()
self.bind("<Key>", self.key_handler)
self.bind("<Return>", self.return_key_handler)
self.bind("<BackSpace>", self.backspace_handler)
self.bind("<Escape>", self.esc_handler)
##self.debugger()
def place_frames(self):
display_frame = DisplayContainer(self
).grid(row=0, column=0, sticky='nsew')
buttons_frame = ButtonsContainer(self
).grid(row=1, column=0, sticky='nsew')
def debugger(self):
print(f'AGGREGATOR current content: {self.aggregator}')
print(f'AGGREGATOR current status: {self.aggregator_status}')
print(f'new_entry: {self.new_entry}')
print(f'decimal_separator: {self.decimal_separator}')
print(f'Current OPERATOR: {self.operator}')
print(f'first_operator value: {self.first_operator}')
print(f'second_operator value: {self.second_operator}')
print(f'first_operator_status: {self.first_operator_status}')
print(f'TOTAL: {self.total}')
print('--------------------------------------------------')
def key_handler(self, event):
'''Handles inputs comming from the keyboard.
This function only accepts four types of keyboard inputs:
(1) Numbers
(2) Basic math operators: +, -, *, /
(3) '=' key
(4) ',' or '.' for decimal separator
Obs: Other keys are ignored
Cases (1) and (4) make the respective value to be shown at
the calculator display.
Case (2) calls the operator handling, that either stores a
value or executes an operation.
Case (3) calls the resolving function.
'''
if event.char.isdigit():
numerical_char = event.char
self.put_char_on_display(numerical_char)
#self.debugger()
elif event.char in ('+', '-', '*', '/'):
operator_char = event.char
self.operators_handler(operator_char)
elif event.char == '=':
self.resolve_operation()
elif event.char == ',' or event.char == '.':
# There can be only one decimal separator at time.
#
# Also, self.new_entry checks if it´s the initial entry
# at first or second number. If so, considers its
# beggining as been a decimal of 0. That´s the case
# when user presses comma/dot at the beggining of the entry.
if self.decimal_separator == False:
if self.new_entry:
zeropoint_char = '0.'
self.put_char_on_display(zeropoint_char)
self.decimal_separator = True
#self.debugger()
else:
decimal_char = '.'
self.put_char_on_display(decimal_char)
self.decimal_separator = True
#self.debugger()
def buttons_handler(self, button):
'''Handles inputs comming from the calculator´s buttons.
As function key_handler does, it handles the following
inputs:
(1) Numbers, that are displayed or stored.
(2) Basic math operators, that stores a value and operator.
or process an operation, according to context.
(3) Resolves an operation with the values and operator stored.
(4) Sets an decimal separator
'''
if button in range(10):
numerical_char = str(button)
self.put_char_on_display(numerical_char)
#self.debugger()
elif button in ('+', '-', '*', '/'):
operator_char = str(button)
self.operators_handler(operator_char)
elif button == '=':
self.resolve_operation()
elif button == '.':
# There can be only one decimal separator at time.
#
# Also, self.new_entry checks if it´s the initial entry
# at first or second number. If so, clicking or typing the
# comma or dot makes the program consider the beggining as
# been a decimal of zero.
# That´s the case when user presses comma/dot at the
# beggining of the entry.
if self.decimal_separator == False:
if self.new_entry:
zeropoint_char = '0.'
self.put_char_on_display(zeropoint_char)
self.decimal_separator = True
#self.debugger()
else:
decimal_char = '.'
self.put_char_on_display(decimal_char)
self.decimal_separator = True
#self.debugger()
def return_key_handler(self, event):
self.resolve_operation()
def backspace_handler(self, event):
if self.aggregator:
del self.aggregator[-1]
self.display_chars.set(self.aggregator)
#self.debugger()
def esc_handler(self, event):
self.set_to_default()
def put_char_on_display(self, char):
'''Makes an numeric input from keyboard or button be displayed.
This function handles two situations:
- Checks if it is the beggining of the input, through the self.new_entry variable. If true, clears the inputs aggregator and displays the new value passed, allowing posterior values to be added.
- If chars input is process is already in course, keep adding the values passed through the params into the inputs aggregator.
The aggregator variable self.aggregator progressivly gets numbers or a decimal separator to form a value that will be stored when an operator or the equal sign is activated.
'''
if self.new_entry == True:
self.aggregator = []
self.aggregator.append(char)
self.display_chars.set(self.aggregator)
self.aggregator_status = 'Active'
self.new_entry = False
else:
self.aggregator.append(char)
self.display_chars.set(self.aggregator)
self.aggregator_status = 'Active'
def operators_handler(self, char):
'''Gets an operator char and handles as context.
Set an operator via button click or keyboard envolves three situations:
(1) If no value is stored yet, and inputs are already in course.
This case, makes the value in aggregator be stored as the first operand.
(2) First operand is already stored, no new values are been inserted. The program then changes the operator, if it´s different.
(3) If first operand is already stored, a new input is in course.
The program stores the value as the second operand and executes the operation according to the operator set.
'''
# Assingn the first operand
if self.first_operator_status == False:
self.first_operator = self.get_values_from_aggregator()
self.first_operator_status = True
self.operator = char
self.new_entry = True
self.decimal_separator = False
self.aggregator_status = 'Inactive'
#self.debugger()
else:
# Handles user changing the operators
if (self.first_operator_status == True and
self.aggregator_status == 'Inactive'):
if char != self.operator:
self.operator = char
#self.debugger()
else:
# If user press any operator after typing the second value
# the value on display will be assingned in second_operator
# variable and resolve_operation() will be called.
self.second_operator = self.get_values_from_aggregator()
self.operator = char
self.aggregator_status = 'Inactive'
self.resolve_operation()
def get_values_from_aggregator(self):
'''Converts the content of aggregator in a float number and
returns it.
If nothing is inserted into aggregator (and it´s empty), then it means that the value is zero.
'''
values = ''.join(self.aggregator)
if values:
return float(values)
else:
return 0
def resolve_operation(self):
'''Stores value as first or second operator, then executes
the corresponding operation. So, call the finish method
with its result as parameter.
'''
if self.first_operator_status == False:
self.first_operator = self.get_values_from_aggregator()
else:
self.second_operator = self.get_values_from_aggregator()
if self.operator == '+':
result = self.first_operator + self.second_operator
self.finish(result)
elif self.operator == '-':
result = self.first_operator - self.second_operator
self.finish(result)
elif self.operator == '*':
result = self.first_operator * self.second_operator
self.finish(result)
elif self.operator == '/':
try:
result = self.first_operator / self.second_operator
except ZeroDivisionError as error:
self.error = error
self.finish(0)
except:
self.error = error
self.finish(0)
else:
self.finish(result)
def finish(self, result):
# Displays the result and sets variables for new operations.
self.total = result
self.display_chars.set(f'{self.total}')
self.aggregator = [str(self.total)]
self.aggregator_status = 'Inactive'
self.new_entry = True
self.decimal_separator = False
self.first_operator_status = False
if self.error:
if isinstance(self.error, ZeroDivisionError):
self.set_to_default()
self.display_chars.set('ZeroDivisionError')
else:
set_to_default()
self.display_chars.set('Unknown Error')
#self.debugger()
def set_to_default(self):
'''Sets program to its default parameters..
This happens if an error is found or CLEAR is activated.
'''
self.display_chars.set(0)
self.aggregator = []
self.operator = None
self.decimal_separator = False
self.total = 0
self.error = None
self.first_operator = 0
self.second_operator = 0
self.first_operator_status = False
self.new_entry = True
self.aggregator_status = 'Inactive'
#self.debugger()
class DisplayContainer(tkinter.Frame):
def __init__(self, parent):
super().__init__(parent)
self.parent = parent
tkinter.Label(
self,
background='lightgrey',
relief='ridge',
border=5,
textvariable=self.parent.display_chars,
).pack(fill='both', expand=1)
class ButtonsContainer(tkinter.Frame):
def __init__(self, parent):
super().__init__(parent)
self.parent = parent
for x in range(0, 5):
self.rowconfigure(x, weight=1)
if x < 4:
self.columnconfigure(x, weight=1)
self.create_buttons()
def create_buttons(self):
pad = 15
row = 0
column = 0
for i in range(10):
if i == 0:
tkinter.Button(
self,
text=i,
padx=pad,
pady=pad,
command=lambda n=i: self.parent.buttons_handler(n)
).grid(row=3, column=1, sticky='nsew')
else:
tkinter.Button(
self,
text=i,
padx=pad,
pady=pad,
command=lambda n=i: self.parent.buttons_handler(n)
).grid(row=row, column=column, sticky='nsew')
if column == 2:
column = 0
row += 1
else:
column += 1
for i in [
['+', 0, 3], ["-", 1, 3],
['*', 2, 3], ['/', 3, 3],
['.', 3, 0], ['=', 3, 2],
['CLEAR', 4, 0]
]:
if i[0] == 'CLEAR':
tkinter.Button(
self,
text=i[0],
padx=pad,
pady=pad,
command=self.parent.set_to_default
).grid(row=i[1], column=i[2], columnspan=4, sticky='nsew')
elif i[0] == '=':
tkinter.Button(
self,
text=i[0],
padx=pad,
pady=pad,
command=self.parent.resolve_operation
).grid(row=i[1], column=i[2], sticky='nsew')
else:
tkinter.Button(
self,
text=i[0],
padx=pad,
pady=pad,
command=lambda n=i[0]: self.parent.buttons_handler(n)
).grid(row=i[1], column=i[2], sticky='nsew')
Difficulties that still exist:
The button layout code can be more efficient, but I did not understood 100% the suggestions of the old topic, and not feel confident to apply these changes.