diff --git a/README.md b/README.md index f5a76ce..31e068b 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,66 @@ # HEM Calculator A **Graphical User Interface (GUI)** calculator application built with Python's `tkinter` library. -This calculator supports both basic arithmetic operations and advanced functions like square root, power, percentage, and binary/decimal conversions. -This was for a group coursework academic and educational project. +Originally developed for an academic group coursework project. +This upgraded version supports a wide range of basic and advanced mathematical functions, +error handling, and additional tools such as binary / decimal conversion. -## Tech Stack +## 🚀 Tech Stack ![Build Status](https://img.shields.io/badge/build-passing-brightgreen) ![License](https://img.shields.io/badge/license-MIT-blue) ![Python](https://img.shields.io/badge/Python-3.9+-blue) ![Tkinter](https://img.shields.io/badge/Tkinter-8.6+-blue) -## Features +## ✨ Features -- **Basic Operations:** +### Basic Operations: - Addition (`+`) - Subtraction (`-`) - Multiplication (`*`) - Division (`/`) + - Decimal support -- **Advanced Functions:** - - Square Root (`√`) +### Advanced Functions: + - Square Root (`sqrt()`) - Exponentiation (`x^y`) - Percentage (`%`) - - Binary to Decimal & Decimal to Binary Conversion + - Factorial (`!`) + - Trigonometric functions (degrees): + - sin(x) (`sin()`) + - cos(x) (`cos()`) + - tan(x) (`tan()`) + - Logarithmic functions: + - log₁₀(x) (`log()`) + - ln(x) (`ln()`) + - Absolute value (`abs()`) + - Rounding (`round()`) + - Constants π, e -## Installation +### Conversion tools +- Binary → Decimal +- Decimal → Binary + +## 📥 Installation 1. Clone the repository: ```bash git clone https://github.com/morganmdx/randcoursework.git ``` -## Usage +## 🖥️ Usage Once you run the program, a GUI window will appear. You can: Perform basic arithmetic operations by clicking the respective buttons. Use the advanced features (square root, exponentiation, percentage, and conversions) by clicking on the respective buttons. -## Dependencies +## 📦 Dependencies - Python 3.x - tkinter (standard Python library for GUI development) -## Contributing +## 🤝 Contributing Contributions are welcome! If you have suggestions or improvements, feel free to open an issue or submit a pull request. -## License +## 📜 License This project is licensed under the MIT License. License subject to change. See the LICENSE file for details. diff --git a/calculator.py b/calculator.py index 9b94170..b734133 100644 --- a/calculator.py +++ b/calculator.py @@ -9,6 +9,7 @@ from threading import Thread #This is used to present decimal numbers as this is important in certain calculations. from decimal import Decimal +import re #This code uses Tkinter to define a Calculator class for a basic calculator app. The background colour and title of the main window are set in the constructor (__init__ method). #The Tkinter window reference is kept in the self.root variable, and light blue is set as the background colour. @@ -32,31 +33,38 @@ def __init__(self, root): # Entry field for displaying and inputting numbers self.entry = tk.Entry(root, font=custom_font) - self.entry.grid(row=1, column=0, rowspan=2,columnspan=5, padx=10, pady=10, sticky="nsew") # Add sticky option + self.entry.grid(row=1, column=0, rowspan=2,columnspan=3, padx=10, pady=10, sticky="nsew") # Add sticky option + # this makes entry key trigger expression evaluation + self.entry.bind("", lambda event: self.evaluate_expression()) # These are just Basic operation buttons operations = [ - ('Square Root', self.square_root), - ('Power Of', self.power_of), + ('√', self.square_root), + ('^', self.power_of), ('%', self.percentage), ('*', self.multiplication), ('+', self.addition), ('-', self.subtraction), - ('/', self.division) + ('/', self.division), + ('round', self.round), + ('abs', self.abs), + ('sin', self.sin), + ('cos', self.cos), + ('tan', self.tan) ] #Here we created the operation buttons #for loop which iterates over each item in the operations list for i, (text, command) in enumerate(operations): operation_button = tk.Button(root, text=text, padx=40, pady=20, bg='light yellow', bd=0, command=command, width=2) - operation_button.grid(row=i // 2 + 3, column=i % 2 + 3, padx=5, pady=5) + operation_button.grid(row=i // 3 + 3, column=i % 3 + 3, padx=5, pady=5) # Number buttons # Uses a lambda function to capture the current value of number and passes it to the self.insert_number method. buttons = [] for number in range(10): - button = tk.Button(root, text=str(number), padx=40, pady=20, bg='light yellow', bd=0, command=lambda num=number: self.insert_number(num), width=2) + button = tk.Button(root, text=str(number), padx=40, pady=20, bg='white', bd=0, command=lambda num=number: self.insert_number(num), width=2) buttons.append(button) # Place number buttons (0 to 9) @@ -67,28 +75,66 @@ def __init__(self, root): for pos, button in zip(positions, buttons[:13]): button.grid(row=pos[0], column=pos[1], padx=5, pady=5) + # create a frame to hold both log and ln buttons + log_frame = tk.Frame(root, bg=self.bg_color, bd=0) + log_frame.grid(row=2, column=5, padx=5, pady=5, sticky="nsew") + log10_button = tk.Button(log_frame, text="log₁₀", padx=20, pady=5, bg='light yellow', command=self.log_10, width=4) + log10_button.pack(side="top", fill="both", expand=True) + separator = tk.Frame(log_frame, bg="gray70", height=1) + separator.pack(fill="x") + ln_button = tk.Button(log_frame, text="ln", padx=20, pady=5, bg='light yellow', command=self.ln, width=4) + ln_button.pack(side="bottom", fill="both", expand=True) + # Add Clear button clear_button = tk.Button(root, text="Clear", padx=40, pady=20, bg='#E8C1C5', command=self.clear, width=2) - clear_button.grid(row=7, column=1, columnspan=1, padx=5, pady=5) # Set row and column for clear button + clear_button.grid(row=3, column=7, columnspan=1, padx=5, pady=5) # Set row and column for clear button + + # Add backspace button + backspace_button = tk.Button(root, text="⌫", padx=40, pady=20, bg='#E8C1C5', command=self.backspace, width=2) + backspace_button.grid(row=2, column=7, columnspan=1, padx=5, pady=5) # Set row and column for backspace button + + # create a frame to hold both ( and ) buttons + paren_frame = tk.Frame(root, bg=self.bg_color, bd=0) + paren_frame.grid(row=6, column=2, padx=5, pady=5, sticky="nsew") + # Add open parenthesis button + open_paren_button = tk.Button(paren_frame, text="(", padx=20, pady=5, bg='light yellow', command=self.insert_open_paren, width=2) + open_paren_button.pack(side="top", fill="both", expand=True) + separator = tk.Frame(paren_frame, bg="gray70", height=1) + separator.pack(fill="x") + # Add close parenthesis button + close_paren_button = tk.Button(paren_frame, text=")", padx=20, pady=5, bg='light yellow', command=self.insert_close_paren, width=2) + close_paren_button.pack(side="bottom", fill="both", expand=True) + + # create a frame to hold both e and π buttons + number_frame = tk.Frame(root, bg=self.bg_color, bd=0) + number_frame.grid(row=6, column=0, padx=5, pady=5, sticky="nsew") + # Add euler's number button + e_button = tk.Button(number_frame, text="e", padx=20, pady=5, bg='light yellow', command=self.e, width=2) + e_button.pack(side="top", fill="both", expand=True) + separator = tk.Frame(number_frame, bg="gray70", height=1) + separator.pack(fill="x") + # Add pi number button + pi_button = tk.Button(number_frame, text="π", padx=20, pady=5, bg='light yellow', command=self.pi, width=2) + pi_button.pack(side="bottom", fill="both", expand=True) # Decimal point button decimal_button = tk.Button(root, text=".", padx=40, pady=20, bg='light yellow', bd=0, command=lambda: self.insert_decimal()) - decimal_button.grid(row=7, column=2) + decimal_button.grid(row=2, column=3) # Equals button equals_button = tk.Button(root, text="=", padx=40, pady=20, bg='light yellow', bd=0, command=self.evaluate_expression) - equals_button.grid(row=7, column=3, padx=5, pady=5) + equals_button.grid(row=6, column=7, padx=5, pady=5) # Conversion buttons convert_to_binary_btn = tk.Button(root, text="To Binary", padx=40, pady=20, bg='light yellow', command=self.decimal_to_binary, width=2) - convert_to_binary_btn.grid(row=6, column=0, padx=5, pady=5) + convert_to_binary_btn.grid(row=4, column=7, padx=5, pady=5) convert_to_decimal_btn = tk.Button(root, text="To Decimal", padx=40, pady=20, bg='light yellow', command=self.binary_to_decimal, width=2) - convert_to_decimal_btn.grid(row=6, column=2, padx=5, pady=5) + convert_to_decimal_btn.grid(row=5, column=7, padx=5, pady=5) # Factorial button - factorial_button = tk.Button(root, text="Factorial", padx=40, pady=20, bg='light yellow', command=self.calculate_factorial, width=2) - factorial_button.grid(row=6, column=4, padx=5, pady=5) + factorial_button = tk.Button(root, text="!", padx=40, pady=20, bg='light yellow', command=self.factorial, width=2) + factorial_button.grid(row=2, column=4, padx=5, pady=5) # Adding a button that allows the user to change the background color color_btn1 = tk.Button(root, text="", padx=10, pady=5, bg='lightblue', command=lambda: self.change_bg_color('lightblue')) @@ -114,22 +160,28 @@ def change_bg_color(self, color): self.logo_label.configure(bg=color) # Retrieves current content in the data field and stores it in the varriable 'current' - # Checks wether the current content of the data field contains a valid numeric value - # Either current is a positive integer or a negative integer def insert_number(self, number): current = self.entry.get() - if current and (current[0] == '-' and current[1:].isdigit() or current.isdigit()): + if current == "Error": # if there is an error, clear before typing + self.entry.delete(0, tk.END) + + self.entry.insert(tk.END, str(number)) # just insert the new digit at the end + + # functions inserting open / close parenthesis + def insert_close_paren(self): + text = self.entry.get() + if text == "" or text == "Error": + return + self.entry.insert(tk.END, ')') + def insert_open_paren(self): + text = self.entry.get() + if text == "Error": self.entry.delete(0, tk.END) - self.entry.insert(0, str(current) + str(number)) - else: - self.entry.insert(tk.END, str(number)) + self.entry.insert(tk.END, '(') - # The insert_decimal method is designed to triggered the tkinker decimal point function. - #This code adds a decimal point to the end of the entry field's content if certain conditions are met. + # The insert_decimal method inserts a decimal point at the end of the current input. def insert_decimal(self): - current = self.entry.get() - if current and '.' not in current: - self.entry.insert(tk.END, '.') + self.entry.insert(tk.END, '.') #the binary_to_decimal method is designed to convert a binary number into its decimal equivalent. #It handles both successful conversions and cases where the input is not a valid binary number. @@ -148,94 +200,142 @@ def decimal_to_binary(self): decimal_input = self.entry.get() try: decimal_input = int(decimal_input) - binary_output = bin(decimal_input) + binary_output = bin(decimal_input)[2:] self.entry.delete(0, tk.END) self.entry.insert(0, str(binary_output)) except ValueError: self.entry.delete(0, tk.END) self.entry.insert(0, "Error") - # This adds the square foot function - def square_root(self): - try: - value = float(self.entry.get()) - result = math.sqrt(value) - self.entry.delete(0, tk.END) - self.entry.insert(0, str(result)) - except ValueError: + # This adds the e number + def e(self): + text = self.entry.get() + if text == "Error": self.entry.delete(0, tk.END) - self.entry.insert(0, "Error")#Clear the entry field and insert the string "Error" to indicate that the operation couldn't be performed due to invalid input. + self.entry.insert(tk.END, 'e') - - #This adds the power of function - def power_of(self): - try: - self.entry.insert(tk.END, '**') # Insert '**' into the entry box - except ValueError: + # This adds the pi number + def pi(self): + text = self.entry.get() + if text == "Error": self.entry.delete(0, tk.END) - self.entry.insert(0, "Error")#Clear the entry field and insert the string "Error" to indicate that the operation couldn't be performed due to invalid input. - + self.entry.insert(tk.END, 'π') + # This adds the log operator + def log_10(self): + text = self.entry.get() + if text == "Error": + self.entry.delete(0, tk.END) + self.entry.insert(tk.END, 'log(') - #This adds the percentage function - def percentage(self): - try: - value = float(self.entry.get()) - result = value / 100 #Calculate the percentage of the obtained value by dividing it by 100. + # This adds the ln operator + def ln(self): + text = self.entry.get() + if text == "Error": self.entry.delete(0, tk.END) - self.entry.insert(0, str(result)) - except ValueError: + self.entry.insert(tk.END, 'ln(') + + # This adds the square root operator + def square_root(self): + text = self.entry.get() + if text == "Error": self.entry.delete(0, tk.END) - self.entry.insert(0, "Error")#Clear the entry field and insert the string "Error" to indicate that the operation couldn't be performed due to invalid input. + self.entry.insert(tk.END, 'sqrt(') + + #This adds the power operator + def power_of(self): + text = self.entry.get() + if text == "" or text == "Error": + return + self.entry.insert(tk.END, '^') + + # this adds the factorial operator to the current expression + def factorial(self): + text = self.entry.get() + if text == "" or text == "Error": + return + self.entry.insert(tk.END, '!') + + # This adds the percentage operator to the current expression + def percentage(self): + text = self.entry.get() + if text == "" or text == "Error": + return + self.entry.insert(tk.END, '%') + - #This adds the multiplication function + # This adds the multiplication operator to the current expression def multiplication(self): - try: - value = float(self.entry.get()) - self.entry.delete(0, tk.END) - self.entry.insert(0, str(value) + '*') - except ValueError: - self.entry.delete(0, tk.END) - self.entry.insert(0, "Error")#Clear the entry field and insert the string "Error" to indicate that the operation couldn't be performed due to invalid input. + text = self.entry.get() + if text == "" or text == "Error": + return + self.entry.insert(tk.END, '*') - #This adds the additin function + # This adds the addition operator to the current expression def addition(self): - try: - value = float(self.entry.get()) # This Retrieve the value from the Tkinter entry widget and convert it to a floating-point number. - self.entry.delete(0, tk.END) - self.entry.insert(0, str(value) + '+') - except ValueError: + text = self.entry.get() + if text == "" or text == "Error": self.entry.delete(0, tk.END) - self.entry.insert(0, "Error")#Clear the entry field and insert the string "Error" to indicate that the operation couldn't be performed due to invalid input. + self.entry.insert(tk.END, '+') - #This adds the subtraction function + # This adds the subtraction operator to the current expression def subtraction(self): - try: - value = float(self.entry.get())#Retrieve the value from the Tkinter entry widget and convert it to a floating-point number. + text = self.entry.get() + if text == "Error": self.entry.delete(0, tk.END) - self.entry.insert(0, str(value) + '-') - except ValueError: - self.entry.delete(0, tk.END) - self.entry.insert(0, "Error")#Clear the entry field and insert the string "Error" to indicate that the operation couldn't be performed due to invalid input. + self.entry.insert(tk.END, '-') # allow leading negative number - #This adds the division function + # This adds the division operator to the current expression def division(self): - try: - value = float(self.entry.get())#This Retrieve the value from the Tkinter entry widget and convert it to a floating-point number. + text = self.entry.get() + if text == "" or text == "Error": + return + self.entry.insert(tk.END, '/') + + # This adds the round operator to the current expression + def round(self): + text = self.entry.get() + if text == "Error": self.entry.delete(0, tk.END) - self.entry.insert(0, str(value) + '/') - except ValueError: + self.entry.insert(tk.END, 'round(') + + # This adds the abs operator to the current expression + def abs(self): + text = self.entry.get() + if text == "Error": self.entry.delete(0, tk.END) - self.entry.insert(0, "Error")#Clear the entry field and insert the string "Error" to indicate that the operation couldn't be performed due to invalid input. + self.entry.insert(tk.END, 'abs(') + + # This adds the sin operator to the current expression + def sin(self): + text = self.entry.get() + if text == "Error": + self.entry.delete(0, tk.END) + self.entry.insert(tk.END, 'sin(') + + # This adds the cos operator to the current expression + def cos(self): + text = self.entry.get() + if text == "Error": + self.entry.delete(0, tk.END) + self.entry.insert(tk.END, 'cos(') + + # This adds the tan operator to the current expression + def tan(self): + text = self.entry.get() + if text == "Error": + self.entry.delete(0, tk.END) + self.entry.insert(tk.END, 'tan(') #retrieves the current content of the entry field associated with the class instance and stores it in the variable expression. def evaluate_expression(self): expression = self.entry.get() try: - result = eval(expression) + # call core logic + result = self.parse_and_calculate(expression) self.entry.delete(0, tk.END) self.entry.insert(0, str(result)) - except (ValueError, SyntaxError): + except (ValueError, SyntaxError, ZeroDivisionError, NameError, TypeError): self.entry.delete(0, tk.END) self.entry.insert(0, "Error")#Clear the entry field and insert the string "Error" to indicate that the operation couldn't be performed due to invalid input. @@ -243,22 +343,36 @@ def evaluate_expression(self): def clear(self): self.entry.delete(0, tk.END) - def calculate_factorial(self): - value = self.entry.get() + # This creates the backspace function + def backspace(self): + current = self.entry.get() + if current and current != "Error": + self.entry.delete(len(current) - 1, tk.END) + + # this calculates the factorial for expression evaluation + def calculate_factorial(self, value): try: value = int(value)#Convert the input value to an integer. if value < 0: raise ValueError("Factorial is defined only for non-negative integers.")#Check if the converted value is a non-negative integer. If not, raise a ValueError with an appropriate error message. - result = Decimal(1) - for i in range(2, value + 1): - result *= Decimal(i) - self.entry.delete(0, tk.END) - self.entry.insert(0, str(result)) + + if value < 100000: + result = 1 + for i in range(2, value + 1): + result *= i + return result + else: + # for very large n: show as e^(ln(n!)) using log-gamma + # this is an approximation of the magnitude of n! + lnn_fact = math.lgamma(value + 1) + return f"e^{lnn_fact:.6f}" + except ValueError as e: self.entry.delete(0, tk.END) self.entry.insert(0, str(e)) #Clear the entry field and insert the error message as a string to indicate that the operation couldn't be performed due to invalid input. + """ def calculate_factorial_threaded(self, value): result = math.factorial(value)#uses the math function to calculate the factorial of the given value self.root.after(0, lambda: self.update_gui_with_factorial(result))#prevents potential delays in responsiveness. @@ -266,6 +380,36 @@ def calculate_factorial_threaded(self, value): def update_gui_with_factorial(self, result): self.entry.delete(0, tk.END) self.entry.insert(0, str(result)) + """ # unused and unnecessary + + # separates the calculation logic from the UI + def parse_and_calculate (self, expression): + + # auto fix unmatched parentheses + if expression.count("(") > expression.count(")"): + expression += ")" * (expression.count("(") - expression.count(")")) + elif expression.count("(") < expression.count(")"): + expression = "(" * (expression.count(")") - expression.count("(")) + expression + + expression = re.sub(r'(\d+(\.\d+)?)%', r'(\1/100)', expression) # percentage conversion + expression = re.sub(r'(\d+)!', r'self.calculate_factorial(\1)', expression) # factorial conversion + expression = re.sub(r'\bsqrt\b', r'math.sqrt', expression) # square root conversion + expression = re.sub(r'\^', r'**', expression) # power conversion + expression = re.sub(r'\blog\b', r'math.log10', expression) # log conversion + expression = re.sub(r'\bln\b', r'math.log', expression) # ln conversion + expression = re.sub(r'π', r'math.pi', expression) # pi conversion + expression = re.sub(r'\be\b', r'math.e', expression) # e conversion + expression = re.sub(r'\bsin\(([^)]+)\)', r'math.sin(math.radians(\1))', expression) # sin conversion + expression = re.sub(r'\bcos\(([^)]+)\)', r'math.cos(math.radians(\1))', expression) # cos conversion (radians) + expression = re.sub(r'\btan\(([^)]+)\)', r'math.tan(math.radians(\1))', expression) # tan conversion + expression = re.sub(r'\babs\b', r'abs', expression) # abs conversion + expression = re.sub(r'\bround\b', r'round', expression) # round conversion + + result = eval(expression) + # fix floating point issues for trig functions + if "math.sin" in expression or "math.cos" in expression or "math.tan" in expression: + result = round(result, 10) + return result #This runs the calculator def run_calculator():#Create the main Tkinter window diff --git a/test_calculator.py b/test_calculator.py new file mode 100644 index 0000000..95d8335 --- /dev/null +++ b/test_calculator.py @@ -0,0 +1,74 @@ +import unittest +import tkinter as tk +import math +from calculator import Calculator + +class TestComplexExpressions(unittest.TestCase): + + # this runs before each test creating a hidden tkinter window so the calculator class can initialize + def setUp(self): + self.root = tk.Tk() + self.root.withdraw() + self.calc = Calculator(self.root) + + # this runs after each test destroying the window to clean up memory + def tearDown(self): + self.root.destroy() + + # order of operations check + def test_order_of_operations(self): + result = self.calc.parse_and_calculate("10+2*5") + self.assertEqual(result, 20) + + # power operator conversion check + def test_power_conversion(self): + result = self.calc.parse_and_calculate("2^3+4^2") + self.assertEqual(result, 24) + + # nested functions check + def test_nested_functions(self): + # simple nesting + result = self.calc.parse_and_calculate("sqrt(16)+16") + self.assertEqual(result, 20.0) + + # complex nesting + result = self.calc.parse_and_calculate("sqrt(3^2+4^2)") + self.assertEqual(result, 5.0) + + # math function inside math function + result = self.calc.parse_and_calculate("sqrt(log(10000))") + self.assertEqual(result, 2.0) + # decimals and logarithms + result = self.calc.parse_and_calculate("log(sqrt(10))") + self.assertEqual(result, 0.5) + + # trig inside math function + result = self.calc.parse_and_calculate("sqrt(sin(90))") + self.assertEqual(result, 1.0) + # mixing trig, power and decimal results + result = self.calc.parse_and_calculate("sin(30)^2") + self.assertEqual(result, 0.25) + + # this checks if the string replacement for degrees/radian breaks the syntax of surrounding math + def test_trig_arithmetic_mix(self): + # (internal logic converts degrees to radians) + result = self.calc.parse_and_calculate("10*sin(90)") + self.assertEqual(result, 10.0) + + # custom operator stability + def test_factorial_and_percent(self): + result = self.calc.parse_and_calculate("3!+10") + self.assertEqual(result, 16) + + result = self.calc.parse_and_calculate("200*50%") + self.assertEqual(result, 100.0) + # mixing factorial with percent + result = self.calc.parse_and_calculate("0!+50%") + self.assertEqual(result, 1.5) + + # parentheses, factorial, percent + result = self.calc.parse_and_calculate("(4!+6)*10%") + self.assertEqual(result, 3.0) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file