diff --git a/abacus_core.py b/abacus_core.py index f108d3f..220e299 100644 --- a/abacus_core.py +++ b/abacus_core.py @@ -1,17 +1,13 @@ - class AbacusCore: def __init__(self): - self._input_string = input + self._input_string = "" self._vars = dict() self._funcs = dict() self._vars['result'] = 0 - self._vars['alphabet'] = 28 - self._vars['alpha'] = 1 - self._vars['beta'] = 2 self._operators = ['+', '-', '/', '*'] self._delimiters = ['(', ')', '[', ']', ';', ' '] @@ -23,51 +19,107 @@ class AbacusCore: return self._funcs def add_var(self, name, value): + name = name.strip() + if not name: + raise ValueError("Имя переменной не может быть пустым") self._vars[name] = value - def _calculate(self, input_str): - print('input:', input_str) - try: - return eval(input_str) - except ZeroDivisionError: - res = 'Division by Zero' - except NameError as e: - res = 'Variable '+ e.name + ' not exists' - except SyntaxError as e: - res = 'Syntax Error' - return res + def add_func(self, name, value): + name = name.strip() + if not name: + raise ValueError("Имя функции не может быть пустым") + self._funcs[name] = value def parse_input(self, input_str): - ''' - versuche input_str zu parsen in comment vars und andere teile - :param input_str: - :return: - ''' - comment = None + """ + Execute a multiline input sequentially, line by line. + + All lines share the same variable/function dictionaries, so a + variable created on one line is immediately available on the next. + """ + if input_str is None: + raise ValueError("Leere Eingabe") + + if not input_str.strip(): + raise ValueError("Leere Eingabe") + + lines = input_str.splitlines() + results = [] + last_comment = None + last_name = None + last_value = None + + for line in lines: + if not line.strip(): + continue + + result = self._parse_line(line) + + if result is None: + continue + + orig, name, value, comment = result + results.append(result) + last_name = name + last_value = value + last_comment = comment + + if not results: + raise ValueError("Leere Eingabe") + + # The notebook displays the result of the last executable line. + return input_str, last_name, last_value, last_comment + + def _parse_line(self, input_str): + """Execute exactly one line.""" orig_input_str = input_str - if len(input_str) < 1: # leere eingabe wird nicht akzeptiert - return - chunks = input_str.split('#') - if len(chunks) > 1: - input_str = chunks[0] - comment = ' '.join(chunks[1:]) - chunks = input_str.split('=') # gibt es variablenzuweisung? + input_str = input_str.strip() + + if not input_str: + return None + + input_str, comment = self._get_input_wo_commentar(input_str) + + if input_str is None or not input_str.strip(): + return None + + input_str = input_str.strip() + + chunks = input_str.split("=") + if len(chunks) > 2: - raise ValueError('Mehrfachzuweisung wird nicht unterstützt') - elif len(chunks) > 1: - expression = self._with_variable_another_way(chunks[1]) - self._vars[chunks[0]] = expression - return - chunks = input_str.split(':') # gibt es funktionsdefinition? - if len(chunks) > 1: - self._funcs[chunks[0]] = chunks[1] - return - res = self._with_variable_another_way(input_str) - self._vars['result'] = res - return orig_input_str, None, res, comment + raise ValueError("Mehrfachzuweisung wird nicht unterstützt") + if len(chunks) == 2: + name = chunks[0].strip() + expression = chunks[1].strip() + if not name: + raise ValueError("Имя переменной не может быть пустым") + if not expression: + raise ValueError("Значение переменной не может быть пустым") + value = self._with_variable_another_way(expression) + self._vars[name] = value + return orig_input_str, name, value, comment + + chunks = input_str.split(":", 1) + + if len(chunks) == 2: + name = chunks[0].strip() + expression = chunks[1].strip() + + if not name: + raise ValueError("Имя функции не может быть пустым") + if not expression: + raise ValueError("Тело функции не может быть пустым") + + self._funcs[name] = expression + return orig_input_str, name, expression, comment + + result = self._with_variable_another_way(input_str) + self._vars["result"] = result + return orig_input_str, None, result, comment def _with_variable_another_way(self, input_str): input_str, comment = self._get_input_wo_commentar(input_str) @@ -90,6 +142,7 @@ class AbacusCore: return eval(''.join(chunks)) def _is_a_variable_or_func(self, chunk): + chunk = chunk.strip() var = self._vars.get(chunk) if not var: var = self._funcs.get(chunk) @@ -122,7 +175,6 @@ class AbacusCore: if i == input_str_len-1: last_position += 1 temp_var = input_str[first_position:last_position] - print('var', temp_var) var = self._vars.get(temp_var) if var is not None: new_input_str += str(var) @@ -171,6 +223,6 @@ class AbacusCore: raise ValueError() # chunks.reverse() elif chunks_len == 2: - return chunks[1], chunks[0] + return chunks[1].strip(), chunks[0].strip() else: - return chunks[0], None + return chunks[0].strip(), None \ No newline at end of file diff --git a/abacus_globals.json b/abacus_globals.json new file mode 100644 index 0000000..5bc140f --- /dev/null +++ b/abacus_globals.json @@ -0,0 +1,10 @@ +{ + "format": "abacus-global-library", + "version": 1, + "variables": { + "alfa": 20 + }, + "functions": { + "double": "result*2" + } +} \ No newline at end of file diff --git a/abacus_notebook.json b/abacus_notebook.json new file mode 100644 index 0000000..b4dd531 --- /dev/null +++ b/abacus_notebook.json @@ -0,0 +1,69 @@ +{ + "format": "abacus-notebook", + "version": 1, + "variables": { + "result": 25, + "alfa": 20 + }, + "functions": { + "double": "alfa*2" + }, + "cells": [ + { + "step": 1, + "code": "alfa=20", + "result": 0, + "error": null + }, + { + "step": 2, + "code": "alfa+5", + "result": 25, + "error": null + }, + { + "step": 3, + "code": "double:alfa*2", + "result": 25, + "error": null + }, + { + "step": 4, + "code": "", + "result": null, + "error": null + }, + { + "step": 5, + "code": "", + "result": null, + "error": null + }, + { + "step": 6, + "code": "", + "result": null, + "error": null + }, + { + "step": 7, + "code": "", + "result": null, + "error": null + } + ], + "graph": [ + { + "step": 1, + "result": 0.0 + }, + { + "step": 2, + "result": 25.0 + }, + { + "step": 3, + "result": 25.0 + } + ] +} \ No newline at end of file diff --git a/abacus_notebook.py b/abacus_notebook.py new file mode 100644 index 0000000..808e35b --- /dev/null +++ b/abacus_notebook.py @@ -0,0 +1,1271 @@ +import json +import math +import sys +from pathlib import Path + +from PySide6.QtCore import Qt, QSize, QStringListModel, QEvent +from PySide6.QtGui import ( + QColor, QFont, QIcon, QPainter, QPalette, QPixmap, + QSyntaxHighlighter, QTextCharFormat, QTextCursor, +) +from PySide6.QtWidgets import ( + QApplication, QCompleter, QDialog, QFileDialog, QFrame, QHBoxLayout, + QLabel, QListWidget, QMainWindow, QPushButton, QScrollArea, + QSizePolicy, QTextBrowser, QTextEdit, QVBoxLayout, QWidget, + QWidgetAction, QDockWidget, QCheckBox, QTabWidget, QListWidgetItem, +) + +from abacus_core import AbacusCore + + +STYLE = """ +QMainWindow, QWidget { background:#0d1117; color:#e6edf3; font-family:"Segoe UI"; } +QMenuBar { background:#111820; color:#c9d1d9; border-bottom:1px solid #202a36; } +QMenuBar::item:selected, QMenu::item:selected { background:#1b2531; } +QMenu { background:#111820; color:#c9d1d9; border:1px solid #202a36; } +QFrame#cell { background:#111820; border:1px solid #202a36; border-radius:12px; } +QFrame#cellError { background:#21171b; border:1px solid #7f3039; border-radius:12px; } +QLabel#prompt { color:#58a6ff; font-family:"JetBrains Mono"; font-weight:700; } +QLabel#resultTitle { color:#718096; font-size:11px; font-weight:700; } +QLabel#resultValue { color:#6ee7b7; font-family:"JetBrains Mono"; font-size:14px; font-weight:700; } +QLabel#errorValue { color:#ff8a8a; font-family:"JetBrains Mono"; } +QTextEdit#cellEditor { background:#0b0f14; color:#e6edf3; border:1px solid #344454; + border-radius:6px; padding:2px 8px; font-family:"JetBrains Mono"; font-size:13px; } +QTextEdit#cellEditor:focus { border:1px solid #3b82f6; } +QPushButton { background:#2563eb; color:white; border:none; border-radius:7px; padding:7px 12px; font-weight:700; } +QPushButton:hover { background:#3475ef; } +QPushButton#small { background:#1b2531; color:#d7e0ea; border:1px solid #3a4a5c; padding:3px 10px; min-height:24px; font-size:12px; } +QPushButton#small:hover { background:#263649; color:#ffffff; } +QListWidget { background:#0d131a; color:#e6edf3; border:1px solid #202a36; border-radius:8px; } +QListWidget::item { color:#e6edf3; padding:7px; border-radius:6px; } +QListWidget::item:hover { background:#17212c; color:#ffffff; } +QListWidget::item:selected { background:#1d4f85; color:#ffffff; } +QListWidget::item:selected:!active { background:#26384a; color:#ffffff; } +QListWidget::indicator { + width:16px; height:16px; + border:1px solid #9aa8b8; + border-radius:3px; + background:#263342; +} +QListWidget::indicator:hover { + border:1px solid #58a6ff; + background:#33465a; +} +QListWidget::indicator:checked { + background:#2563eb; + border:1px solid #7db7ff; +} +QListWidget::indicator:checked:hover { + background:#3475ef; +} +QCheckBox { color:#e6edf3; spacing:8px; } +QCheckBox::indicator { width:16px; height:16px; border:1px solid #718096; border-radius:3px; background:#111820; } +QCheckBox::indicator:hover { border:1px solid #58a6ff; background:#17212c; } +QCheckBox::indicator:checked { background:#2563eb; border:1px solid #58a6ff; } +QCheckBox::indicator:checked:hover { background:#3475ef; } +QScrollArea { border:none; background:#0d1117; } +QTabWidget::pane { border:1px solid #202a36; background:#0d1117; } +QTabBar::tab { background:#111820; color:#7d8b99; border:1px solid #202a36; padding:8px 15px; } +QTabBar::tab:selected { background:#17212c; color:#f0f6fc; } +QStatusBar { background:#0b0f14; color:#7d8b99; border-top:1px solid #202a36; } +""" + + +class AbacusHighlighter(QSyntaxHighlighter): + def __init__(self, document): + super().__init__(document) + self.number = QTextCharFormat() + self.number.setForeground(QColor("#79c0ff")) + self.operator = QTextCharFormat() + self.operator.setForeground(QColor("#ff7b72")) + self.comment = QTextCharFormat() + self.comment.setForeground(QColor("#6e7681")) + self.comment.setFontItalic(True) + self.keyword = QTextCharFormat() + self.keyword.setForeground(QColor("#ffa657")) + + def highlightBlock(self, text): + comment_pos = text.find("#") + code = text if comment_pos < 0 else text[:comment_pos] + if comment_pos >= 0: + self.setFormat(comment_pos, len(text) - comment_pos, self.comment) + + i = 0 + while i < len(code): + if code[i].isdigit() or ( + code[i] == "." and i + 1 < len(code) and code[i + 1].isdigit() + ): + start = i + i += 1 + while i < len(code) and (code[i].isdigit() or code[i] == "."): + i += 1 + self.setFormat(start, i - start, self.number) + else: + i += 1 + + for char in "+-*/=():[];": + start = 0 + while True: + pos = code.find(char, start) + if pos < 0: + break + self.setFormat(pos, 1, self.operator) + start = pos + 1 + + colon = code.find(":") + if colon > 0: + name = code[:colon].strip() + if name: + pos = code.find(name) + self.setFormat(pos, len(name), self.keyword) + + +class CellEditor(QTextEdit): + def __init__(self, run_callback): + super().__init__() + self.run_callback = run_callback + self.completer = None + + self.setObjectName("cellEditor") + self.setAcceptRichText(False) + self.setLineWrapMode(QTextEdit.NoWrap) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setMinimumWidth(0) + + self._line_height = max(1, self.fontMetrics().lineSpacing()) + self._one_line_height = self._line_height + 8 + self.setFixedHeight(self._one_line_height) + self.textChanged.connect(self._adjust_height) + + def _adjust_height(self): + # Only explicit newline characters affect the editor height. + # Width, word wrapping and QTextLayout are deliberately ignored. + lines = max(1, self.toPlainText().count("\n") + 1) + height = ( + self._one_line_height + + (lines - 1) * self._line_height + ) + if self.height() != height: + self.setFixedHeight(height) + self.updateGeometry() + parent = self.parentWidget() + if parent is not None: + parent.updateGeometry() + + def set_completer(self, completer): + self.completer = completer + completer.setWidget(self) + completer.setCompletionMode(QCompleter.PopupCompletion) + completer.setCaseSensitivity(Qt.CaseInsensitive) + + popup = completer.popup() + popup.setMinimumWidth(320) + popup.setMaximumHeight(180) + popup.setStyleSheet( + "QListView {" + "background:#111820;" + "color:#e6edf3;" + "border:1px solid #344454;" + "padding:4px;" + "}" + "QListView::item {" + "padding:5px 8px;" + "}" + "QListView::item:selected {" + "background:#2563eb;" + "color:white;" + "}" + ) + + completer.activated.connect(self.insert_completion) + + def completion_prefix(self): + cursor = self.textCursor() + text = cursor.block().text() + pos = cursor.positionInBlock() + start = pos + + while start > 0 and ( + text[start - 1].isalnum() or text[start - 1] == "_" + ): + start -= 1 + + return text[start:pos] + + def insert_completion(self, completion): + prefix = self.completion_prefix() + cursor = self.textCursor() + + if prefix: + cursor.movePosition( + QTextCursor.Left, + QTextCursor.KeepAnchor, + len(prefix), + ) + + cursor.insertText(completion) + self.setTextCursor(cursor) + + def show_completion(self, all_names=False): + if not self.completer: + return + + prefix = self.completion_prefix() + + if not prefix and not all_names: + self.completer.popup().hide() + return + + self.completer.setCompletionPrefix(prefix) + + if self.completer.completionCount() == 0: + self.completer.popup().hide() + return + + rect = self.cursorRect() + rect.setTop(rect.bottom() + 6) + rect.setHeight(1) + rect.setWidth(320) + self.completer.complete(rect) + + def keyPressEvent(self, event): + # Shift+Enter must work even while the completion popup is open. + if event.key() in (Qt.Key_Return, Qt.Key_Enter) and ( + event.modifiers() & Qt.ShiftModifier + ): + if self.completer: + self.completer.popup().hide() + self.run_callback() + return + + if self.completer and self.completer.popup().isVisible(): + if event.key() in (Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab): + event.ignore() + return + + if event.key() == Qt.Key_Escape: + self.completer.popup().hide() + return + + # Ctrl+Space: force completion list. + if event.key() == Qt.Key_Space and ( + event.modifiers() & Qt.ControlModifier + ): + self.show_completion(True) + return + + super().keyPressEvent(event) + self.show_completion() + + +class NotebookCell(QFrame): + def __init__(self, notebook, number=1, code=""): + super().__init__() + self.notebook = notebook + self.number = number + self.value = None + self.error = None + self.setObjectName("cell") + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 7) + layout.setSpacing(4) + + header = QHBoxLayout() + self.prompt = QLabel() + self.prompt.setObjectName("prompt") + header.addWidget(self.prompt) + header.addStretch() + + for text, callback in ( + ("Run", self.run), + ("+", self.add_after), + ("×", self.delete), + ): + button = QPushButton(text) + button.setObjectName("small") + button.clicked.connect(callback) + header.addWidget(button) + + layout.addLayout(header) + + self.editor = CellEditor(self.run) + self.highlighter = AbacusHighlighter(self.editor.document()) + model = QStringListModel() + self.completer = QCompleter(model, self) + self.completion_model = model + self.editor.set_completer(self.completer) + self.editor.setPlainText(code) + layout.addWidget(self.editor) + + self.result_title = QLabel("result:") + self.result_title.setObjectName("resultTitle") + layout.addWidget(self.result_title) + + self.result_label = QLabel("") + self.result_label.setObjectName("resultValue") + self.result_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.result_label.setSizePolicy( + QSizePolicy.Expanding, QSizePolicy.Fixed + ) + self.result_label.setFixedHeight(20) + layout.addWidget(self.result_label) + + self.set_number(number) + + def set_number(self, number): + self.number = number + self.prompt.setText(f"In [{number}]") + + def set_completion_names(self, names): + self.completion_model.setStringList(sorted(set(names))) + + def run(self): + self.notebook.run_cell(self) + + def add_after(self): + self.notebook.add_cell_after(self) + + def delete(self): + self.notebook.delete_cell(self) + + def show_result(self, value): + self.value = value + self.error = None + self.setObjectName("cell") + self.style().unpolish(self) + self.style().polish(self) + self.result_title.setText("result:") + self.result_label.setObjectName("resultValue") + self.result_label.setText(str(value)) + + def show_error(self, error): + self.value = None + self.error = str(error) + self.setObjectName("cellError") + self.style().unpolish(self) + self.style().polish(self) + self.result_title.setText("error:") + self.result_label.setObjectName("errorValue") + self.result_label.setText(str(error)) + + def clear_output(self): + self.value = None + self.error = None + self.result_title.setText("result:") + self.result_label.setText("") + + def to_dict(self): + return { + "step": self.number, + "code": self.editor.toPlainText(), + "result": self.value, + "error": self.error, + } + + +class GraphWidget(QWidget): + def __init__(self): + super().__init__() + self.variables = {} + + def set_variables(self, variables): + self.variables = dict(variables) + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + painter.fillRect(self.rect(), QColor("#0b0f14")) + + area = self.rect().adjusted(60, 30, -25, -55) + painter.setPen(QColor("#273442")) + painter.drawRect(area) + + numeric = [ + (name, float(value)) + for name, value in self.variables.items() + if isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(float(value)) + ] + + if not numeric: + painter.setPen(QColor("#647383")) + painter.drawText(area, Qt.AlignCenter, "Select numeric variables") + return + + lo = min(0.0, min(value for _, value in numeric)) + hi = max(0.0, max(value for _, value in numeric)) + if math.isclose(lo, hi): + hi = lo + 1.0 + + span = hi - lo + zero_y = area.bottom() - ((0 - lo) / span) * area.height() + + painter.setPen(QColor("#344454")) + painter.drawLine( + area.left(), int(zero_y), area.right(), int(zero_y) + ) + + count = len(numeric) + slot = area.width() / count + bar_width = min(70, slot * 0.62) + + for index, (name, value) in enumerate(numeric): + center_x = area.left() + slot * (index + 0.5) + value_y = area.bottom() - ((value - lo) / span) * area.height() + + top = min(zero_y, value_y) + height = max(2, abs(zero_y - value_y)) + + painter.setBrush(QColor("#3b82f6")) + painter.setPen(QColor("#58a6ff")) + painter.drawRoundedRect( + int(center_x - bar_width / 2), + int(top), + int(bar_width), + int(height), + 5, + 5, + ) + + painter.setPen(QColor("#d6deea")) + painter.drawText( + int(center_x - slot / 2), + int(top - 23 if value >= 0 else zero_y + height + 5), + int(slot), + 18, + Qt.AlignCenter, + f"{value:g}", + ) + + painter.setPen(QColor("#8b98a7")) + painter.drawText( + int(center_x - slot / 2), + area.bottom() + 10, + int(slot), + 30, + Qt.AlignCenter, + name, + ) + + +class HelpDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Abacus - Help") + self.resize(900, 760) + + layout = QVBoxLayout(self) + + browser = QTextBrowser() + browser.setHtml(self._help_html()) + layout.addWidget(browser, 1) + + logo = QLabel() + logo.setAlignment(Qt.AlignCenter) + logo_path = Path(__file__).resolve().parent / "assets" / "abacus_logo.png" + pixmap = QPixmap(str(logo_path)) + if not pixmap.isNull(): + logo.setPixmap( + pixmap.scaled( + 300, 300, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + ) + layout.addWidget(logo, 0, Qt.AlignCenter) + + close_button = QPushButton("Close") + close_button.clicked.connect(self.accept) + layout.addWidget(close_button, 0, Qt.AlignRight) + + @staticmethod + def _help_html(): + return """ +
+ Abacus is a notebook-style calculator. Calculations are written + in cells. A cell may contain one or several lines. Lines inside + a cell are executed sequentially from top to bottom and share + the same variables and functions. +
+anzahl_apfel = 10 +gewicht_pro_apfel = 120 +gesamt = anzahl_apfel * gewicht_pro_apfel+
Leading and trailing spaces are ignored.
+Arithmetic operators are +, -, * and /. + Parentheses can be used in expressions.
+An ordinary expression stores its value in result. + The cell displays the result of its last executed line.
+a = 10 +b = 20 +c = a + b +c * 2+
Lines are executed sequentially. Each line immediately sees + variables created by previous lines.
+double: alpha * 2+
Functions are shown in the Functions panel.
+alpha = 20 # working value+
The Variables and Functions panels can be shown or hidden from + View. Double-click an item to insert its name at the cursor.
+An unchecked item is local. A checked item belongs to the global + library and is loaded when Abacus starts.
+The Graph tab displays selected numeric variables as bars.
+After execution, focus moves to the next cell. If there is no + next cell, Abacus creates one automatically.
+| Shift + Enter | Run current cell and move to the next |
| Ctrl + Space | Show autocomplete |
| F5 | Run all cells |
| Ctrl + N | New notebook |
| Ctrl + S | Export notebook |
| Ctrl + O | Import notebook |
| Ctrl + Q | Exit Abacus |
| F1 | Open Help |