208 lines
6.4 KiB
Python
208 lines
6.4 KiB
Python
"""SymPy Math App — desktop calculator / REPL powered by SymPy and PyQt5."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import sys
|
||
from typing import Any
|
||
|
||
import sympy
|
||
from PyQt5.QtCore import Qt
|
||
from PyQt5.QtWidgets import (
|
||
QApplication,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QLineEdit,
|
||
QMainWindow,
|
||
QPushButton,
|
||
QTextEdit,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
_SEPARATOR = "-" * 50
|
||
_ASSIGNMENT_RE = re.compile(r"^\s*\w+\s*=(?!=)")
|
||
|
||
|
||
class HistoryLineEdit(QLineEdit):
|
||
"""QLineEdit with Up / Down arrow command-history navigation."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self._entries: list[str] = []
|
||
self._cursor: int = 0
|
||
|
||
@property
|
||
def entries(self) -> list[str]:
|
||
return list(self._entries)
|
||
|
||
def append(self, command: str) -> None:
|
||
"""Record *command*, skipping consecutive duplicates."""
|
||
if command and (not self._entries or self._entries[-1] != command):
|
||
self._entries.append(command)
|
||
self._cursor = len(self._entries)
|
||
|
||
def reset_cursor(self) -> None:
|
||
self._cursor = len(self._entries)
|
||
|
||
def keyPressEvent(self, event) -> None: # noqa: N802 – Qt naming
|
||
if event.key() == Qt.Key_Up:
|
||
self._navigate(-1)
|
||
event.accept()
|
||
elif event.key() == Qt.Key_Down:
|
||
self._navigate(1)
|
||
event.accept()
|
||
else:
|
||
super().keyPressEvent(event)
|
||
|
||
def _navigate(self, direction: int) -> None:
|
||
if not self._entries:
|
||
return
|
||
new_index = self._cursor + direction
|
||
if 0 <= new_index < len(self._entries):
|
||
self._cursor = new_index
|
||
self.setText(self._entries[new_index])
|
||
elif new_index >= len(self._entries):
|
||
self._cursor = len(self._entries)
|
||
self.clear()
|
||
|
||
|
||
class MathWindow(QMainWindow):
|
||
"""Main application window for the SymPy calculator."""
|
||
|
||
_BUTTONS = ("Evaluate", "Clear", "Show Variables", "Show History")
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self._namespace: dict[str, Any] = sympy.__dict__.copy()
|
||
self._build_ui()
|
||
|
||
# -- UI construction -------------------------------------------------------
|
||
|
||
def _build_ui(self) -> None:
|
||
self.setWindowTitle("SymPy Math App")
|
||
self.setGeometry(100, 100, 600, 500)
|
||
|
||
root = QWidget()
|
||
self.setCentralWidget(root)
|
||
layout = QVBoxLayout(root)
|
||
|
||
layout.addWidget(QLabel("Enter SymPy command:"))
|
||
|
||
self._command_input = HistoryLineEdit(self)
|
||
self._command_input.setPlaceholderText(
|
||
'e.g., x = Symbol("x"), or x**2 + 2*x + 1, or diff(x**2, x)'
|
||
)
|
||
self._command_input.returnPressed.connect(self._evaluate)
|
||
layout.addWidget(self._command_input)
|
||
|
||
button_row = QHBoxLayout()
|
||
handlers = (self._evaluate, self._clear, self._show_variables, self._show_history)
|
||
for label, handler in zip(self._BUTTONS, handlers):
|
||
btn = QPushButton(label)
|
||
btn.clicked.connect(handler)
|
||
button_row.addWidget(btn)
|
||
layout.addLayout(button_row)
|
||
|
||
layout.addWidget(QLabel("Results:"))
|
||
|
||
self._results_display = QTextEdit()
|
||
self._results_display.setReadOnly(True)
|
||
self._results_display.setPlaceholderText("Results will appear here...")
|
||
layout.addWidget(self._results_display)
|
||
|
||
# -- helpers ---------------------------------------------------------------
|
||
|
||
def _append_result(self, *lines: str) -> None:
|
||
for line in lines:
|
||
self._results_display.append(line)
|
||
|
||
@staticmethod
|
||
def _format_value(value: Any) -> str:
|
||
if isinstance(value, sympy.Basic):
|
||
return sympy.pretty(value)
|
||
return str(value)
|
||
|
||
# -- slots -----------------------------------------------------------------
|
||
|
||
def _evaluate(self) -> None:
|
||
command = self._command_input.text().strip()
|
||
if not command:
|
||
self._append_result("Error: Please enter a command.")
|
||
return
|
||
|
||
self._command_input.append(command)
|
||
|
||
try:
|
||
if _ASSIGNMENT_RE.match(command):
|
||
self._execute_assignment(command)
|
||
else:
|
||
self._evaluate_expression(command)
|
||
self._command_input.clear()
|
||
except Exception as exc:
|
||
self._append_result(f"Command: {command}", f"Error: {exc}", _SEPARATOR)
|
||
|
||
def _execute_assignment(self, command: str) -> None:
|
||
exec(command, {"__builtins__": {}}, self._namespace) # noqa: S102
|
||
self._append_result(f"Command: {command}", "Variable set successfully.", _SEPARATOR)
|
||
|
||
def _evaluate_expression(self, command: str) -> None:
|
||
try:
|
||
result = sympy.sympify(command, locals=self._namespace)
|
||
except (sympy.SympifyError, SyntaxError, TypeError):
|
||
result = eval(command, {"__builtins__": {}}, self._namespace) # noqa: S307
|
||
|
||
self._append_result(
|
||
f"Command: {command}",
|
||
f"Result:\n{self._format_value(result)}",
|
||
_SEPARATOR,
|
||
)
|
||
|
||
def _clear(self) -> None:
|
||
self._command_input.clear()
|
||
self._results_display.clear()
|
||
self._command_input.reset_cursor()
|
||
|
||
def _show_variables(self) -> None:
|
||
builtin_keys = set(sympy.__dict__)
|
||
user_vars = {
|
||
name: val
|
||
for name, val in self._namespace.items()
|
||
if name not in builtin_keys and not name.startswith("_")
|
||
}
|
||
|
||
if not user_vars:
|
||
self._append_result("No variables defined.", _SEPARATOR)
|
||
return
|
||
|
||
self._append_result("Current Variables:")
|
||
for name, value in user_vars.items():
|
||
self._append_result(f" {name} = {self._format_value(value)}")
|
||
self._append_result(_SEPARATOR)
|
||
|
||
def _show_history(self) -> None:
|
||
entries = self._command_input.entries
|
||
if not entries:
|
||
self._append_result("No command history yet.", _SEPARATOR)
|
||
return
|
||
|
||
self._append_result("Command History:")
|
||
for index, cmd in enumerate(entries, 1):
|
||
self._append_result(f" {index}. {cmd}")
|
||
self._append_result(
|
||
_SEPARATOR,
|
||
"Tip: Use Up/Down arrow keys to navigate through history.",
|
||
_SEPARATOR,
|
||
)
|
||
|
||
|
||
def main() -> None:
|
||
app = QApplication(sys.argv)
|
||
window = MathWindow()
|
||
window.show()
|
||
sys.exit(app.exec_())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|