import json import math import sys from PySide6.QtCore import Qt, QSize, QStringListModel from PySide6.QtGui import ( QColor, QFont, QPainter, QSyntaxHighlighter, QTextCharFormat, QTextCursor, ) from PySide6.QtWidgets import ( QApplication, QDockWidget, QFrame, QCompleter, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QMainWindow, QPlainTextEdit, QTextEdit, QPushButton, QStatusBar, QTabWidget, QTableWidget, QTableWidgetItem, QToolBar, QVBoxLayout, QWidget, ) from abacus_core import AbacusCore STYLE = """ QMainWindow, QWidget { background: #0d1117; color: #e6edf3; font-family: "Segoe UI"; font-size: 13px; } QToolBar { background: #111820; border: 0; border-bottom: 1px solid #202a36; spacing: 5px; padding: 6px 8px; } QToolButton { color: #c9d1d9; background: transparent; border-radius: 7px; padding: 7px 10px; } QToolButton:hover { background: #1b2531; color: #ffffff; } QTabWidget::pane { border: 1px solid #202a36; background: #0d1117; } QTabBar::tab { background: #111820; color: #7d8b99; border: 1px solid #202a36; border-bottom: none; padding: 9px 16px; margin-right: 2px; } QTabBar::tab:selected { background: #17212c; color: #f0f6fc; } QDockWidget { color: #aeb9c6; titlebar-close-icon: none; titlebar-normal-icon: none; } QDockWidget::title { background: #111820; border-bottom: 1px solid #202a36; padding: 9px 11px; font-weight: 700; } QFrame#editorCard, QFrame#bottomCard { background: #111820; border: 1px solid #202a36; border-radius: 10px; } QPlainTextEdit { background: #0b0f14; color: #d6deea; border: 1px solid #202a36; border-radius: 9px; selection-background-color: #244a7d; padding: 8px; font-family: "JetBrains Mono", "Consolas", monospace; font-size: 14px; } QLineEdit#singleLine { background: #0b0f14; color: #f0f6fc; border: 1px solid #303b48; border-radius: 9px; padding: 9px 11px; font-family: "JetBrains Mono", "Consolas", monospace; } QLineEdit#singleLine:focus { border: 1px solid #3b82f6; } QPushButton { background: #2563eb; color: white; border: none; border-radius: 8px; padding: 8px 13px; font-weight: 700; } QPushButton:hover { background: #3475ef; } QPushButton#operator { background: #1b2531; color: #cbd5e1; border: 1px solid #293543; min-width: 34px; } QPushButton#operator:hover { background: #253344; color: #ffffff; } QPushButton#secondary { background: #1b2531; color: #b9c4d0; border: 1px solid #293543; } QListWidget, QTableWidget { background: #0d131a; color: #cbd5e1; border: 1px solid #202a36; border-radius: 8px; outline: none; } QListWidget::item { padding: 7px 8px; border-radius: 6px; } QListWidget::item:hover { background: #17212c; } QListWidget::item:selected { background: #1c3153; color: #ffffff; } QHeaderView::section { background: #161f29; color: #9aa8b7; border: none; border-right: 1px solid #202a36; border-bottom: 1px solid #202a36; padding: 7px; } QTableWidget::item { padding: 6px; border-bottom: 1px solid #18212b; } QStatusBar { background: #0b0f14; color: #7d8b99; border-top: 1px solid #202a36; } QScrollBar:vertical { background: #0d131a; width: 9px; } QScrollBar::handle:vertical { background: #303c4a; border-radius: 4px; min-height: 25px; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } """ 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.assignment = QTextCharFormat() self.assignment.setForeground(QColor("#d2a8ff")) self.keyword = QTextCharFormat() self.keyword.setForeground(QColor("#ffa657")) def highlightBlock(self, text): # Comments comment_pos = text.find("#") if comment_pos >= 0: self.setFormat(comment_pos, len(text) - comment_pos, self.comment) code = text[:comment_pos] else: code = text # Numbers 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 # Function definitions: name before ':' colon = code.find(":") if colon > 0: name = code[:colon].strip() if name: pos = code.find(name) self.setFormat(pos, len(name), self.keyword) class LineNumberArea(QWidget): def __init__(self, editor): super().__init__(editor) self.editor = editor def sizeHint(self): return QSize(self.editor.line_number_width(), 0) def paintEvent(self, event): painter = QPainter(self) painter.fillRect(event.rect(), QColor("#0f151c")) block = self.editor.firstVisibleBlock() block_number = block.blockNumber() top = int( self.editor.blockBoundingGeometry(block).translated( self.editor.contentOffset() ).top() ) bottom = top + int(self.editor.blockBoundingRect(block).height()) while block.isValid() and top <= event.rect().bottom(): if block.isVisible() and bottom >= event.rect().top(): painter.setPen(QColor("#596878")) painter.drawText( 0, top, self.width() - 7, self.editor.fontMetrics().height(), Qt.AlignRight, str(block_number + 1), ) block = block.next() top = bottom bottom = top + int(self.editor.blockBoundingRect(block).height()) block_number += 1 class CodeEditor(QPlainTextEdit): def __init__(self): super().__init__() self.line_numbers = LineNumberArea(self) self.error_lines = {} self.completer = None self.blockCountChanged.connect(self.update_line_number_width) self.updateRequest.connect(self.update_line_number_area) self.cursorPositionChanged.connect(self.highlight_current_line) self.update_line_number_width(0) self.highlight_current_line() font = QFont("JetBrains Mono", 11) font.setStyleHint(QFont.Monospace) self.setFont(font) self.setTabStopDistance( self.fontMetrics().horizontalAdvance(" ") * 4 ) def line_number_width(self): digits = len(str(max(1, self.blockCount()))) return 12 + self.fontMetrics().horizontalAdvance("9") * digits def update_line_number_width(self, _): self.setViewportMargins(self.line_number_width(), 0, 0, 0) def update_line_number_area(self, rect, dy): if dy: self.line_numbers.scroll(0, dy) else: self.line_numbers.update( 0, rect.y(), self.line_numbers.width(), rect.height(), ) if rect.contains(self.viewport().rect()): self.update_line_number_width(0) def resizeEvent(self, event): super().resizeEvent(event) rect = self.contentsRect() rect.setWidth(self.line_number_width()) self.line_numbers.setGeometry(rect) def highlight_current_line(self): extra = [] if not self.isReadOnly(): selection = QTextCharFormat() selection.setBackground(QColor("#111a24")) selection.setProperty( QTextCharFormat.FullWidthSelection, True, ) extra_selection = QTextEdit.ExtraSelection() extra_selection.cursor = self.textCursor() extra_selection.format = selection extra.append(extra_selection) # Error lines always get a stronger red background. for line_number, message in self.error_lines.items(): block = self.document().findBlockByNumber(line_number - 1) if not block.isValid(): continue cursor = self.textCursor() cursor.setPosition(block.position()) cursor.setPosition( block.position() + max(1, block.length() - 1), QTextCursor.KeepAnchor, ) selection = QTextCharFormat() selection.setBackground(QColor("#4a1f25")) selection.setForeground(QColor("#ffb4b4")) selection.setProperty( QTextCharFormat.FullWidthSelection, True, ) extra_selection = QTextEdit.ExtraSelection() extra_selection.cursor = cursor extra_selection.format = selection extra.append(extra_selection) self.setExtraSelections(extra) self.line_numbers.update() def set_error_lines(self, errors): self.error_lines = dict(errors) self.highlight_current_line() def clear_error_lines(self): self.error_lines.clear() self.highlight_current_line() def set_completer(self, completer): self.completer = completer completer.setWidget(self) completer.setCompletionMode(QCompleter.PopupCompletion) completer.setCaseSensitivity(Qt.CaseInsensitive) completer.activated.connect(self.insert_completion) 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) else: cursor.insertText(completion) self.setTextCursor(cursor) def completion_prefix(self): cursor = self.textCursor() text = cursor.block().text() pos = cursor.positionInBlock() start = pos while start > 0: char = text[start - 1] if not (char.isalnum() or char == "_"): break start -= 1 return text[start:pos] def show_completion(self, show_all=False): if not self.completer: return prefix = self.completion_prefix() if not prefix and not show_all: self.completer.popup().hide() return self.completer.setCompletionPrefix(prefix) if show_all: self.completer.setCompletionPrefix("") if self.completer.completionCount() == 0: self.completer.popup().hide() return rect = self.cursorRect() rect.setWidth( self.completer.popup().sizeHintForColumn(0) + self.completer.popup().verticalScrollBar().sizeHint().width() ) self.completer.complete(rect) def keyPressEvent(self, event): if self.completer and self.completer.popup().isVisible(): if event.key() in ( Qt.Key_Enter, Qt.Key_Return, Qt.Key_Tab, Qt.Key_Backtab, ): event.ignore() return if event.key() == Qt.Key_Escape: self.completer.popup().hide() return if ( event.key() == Qt.Key_Space and event.modifiers() & Qt.ControlModifier ): self.show_completion(show_all=True) return super().keyPressEvent(event) if self.completer: self.show_completion() class ResultGraph(QWidget): """Small dependency-free graph of numeric results.""" def __init__(self): super().__init__() self.values = [] def set_values(self, values): self.values = values[-50:] self.update() def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) painter.fillRect(self.rect(), QColor("#0b0f14")) r = self.rect().adjusted(45, 20, -20, -35) painter.setPen(QColor("#273442")) painter.drawRect(r) if not self.values: painter.setPen(QColor("#647383")) painter.drawText(r, Qt.AlignCenter, "No numeric results yet") return numeric = [v for v in self.values if math.isfinite(v)] if not numeric: return lo = min(numeric) hi = max(numeric) if abs(hi - lo) < 1e-12: lo -= 1 hi += 1 painter.setPen(QColor("#344454")) for frac in (0.0, 0.5, 1.0): y = int(r.bottom() - frac * r.height()) painter.drawLine(r.left(), y, r.right(), y) painter.setPen(QColor("#5f6f80")) painter.drawText(5, r.top() + 5, 35, 20, Qt.AlignRight, f"{hi:g}") painter.drawText(5, r.bottom() - 15, 35, 20, Qt.AlignRight, f"{lo:g}") if len(numeric) == 1: x = r.center().x() y = r.bottom() - (numeric[0] - lo) / (hi - lo) * r.height() painter.setBrush(QColor("#58a6ff")) painter.drawEllipse(int(x - 4), int(y - 4), 8, 8) return points = [] count = len(numeric) for i, value in enumerate(numeric): x = r.left() + i * r.width() / max(1, count - 1) y = r.bottom() - (value - lo) / (hi - lo) * r.height() points.append((x, y)) painter.setPen(QColor("#58a6ff")) for a, b in zip(points, points[1:]): painter.drawLine(int(a[0]), int(a[1]), int(b[0]), int(b[1])) painter.setBrush(QColor("#58a6ff")) for x, y in points: painter.drawEllipse(int(x - 3), int(y - 3), 6, 6) class AbacusIDE(QMainWindow): def __init__(self): super().__init__() self.core = AbacusCore() self.step = 0 self.results = [] self.setWindowTitle("Abacus IDE") self.resize(1400, 850) self.setMinimumSize(1050, 650) self._build_main() self._build_menu() self._build_toolbar() self._build_docks() self._build_statusbar() self._refresh_sidebars() self.editor.setFocus() def _build_menu(self): menu = self.menuBar() file_menu = menu.addMenu("File") export_action = file_menu.addAction("Export history to JSON...") export_action.triggered.connect(self.export_history) import_action = file_menu.addAction("Import history from JSON...") import_action.triggered.connect(self.import_history) file_menu.addSeparator() reset_action = file_menu.addAction("Reset Abacus") reset_action.triggered.connect(self.reset_abacus) file_menu.addSeparator() exit_action = file_menu.addAction("Exit") exit_action.triggered.connect(self.close) edit_menu = menu.addMenu("Edit") clear_errors = edit_menu.addAction("Clear error highlighting") clear_errors.triggered.connect(self.editor.clear_error_lines) def _build_toolbar(self): toolbar = QToolBar() toolbar.setMovable(False) toolbar.setToolButtonStyle(Qt.ToolButtonTextOnly) self.addToolBar(toolbar) run = toolbar.addAction("▶ Run") run.triggered.connect(self.run_document) run_line = toolbar.addAction("Run line") run_line.triggered.connect(self.run_current_line) toolbar.addSeparator() for symbol in ("+", "-", "*", "/", "(", ")", "[", "]", "="): button = QPushButton(symbol) button.setObjectName("operator") button.clicked.connect( lambda checked=False, s=symbol: self.insert_operator(s) ) toolbar.addWidget(button) toolbar.addSeparator() clear = toolbar.addAction("Clear") clear.triggered.connect(self.editor.clear) toolbar.addSeparator() reset = toolbar.addAction("Reset") reset.triggered.connect(self.reset_abacus) def _build_main(self): self.tabs = QTabWidget() self.setCentralWidget(self.tabs) # Workspace tab workspace = QWidget() workspace_layout = QVBoxLayout(workspace) workspace_layout.setContentsMargins(10, 10, 10, 10) workspace_layout.setSpacing(8) editor_header = QHBoxLayout() label = QLabel("Expression editor") label.setStyleSheet( "font-size: 13px; font-weight: 700; color: #c9d1d9;" ) hint = QLabel( "One command per line • # comments • Enter = run line" ) hint.setStyleSheet("color: #657585;") editor_header.addWidget(label) editor_header.addStretch() editor_header.addWidget(hint) workspace_layout.addLayout(editor_header) self.editor = CodeEditor() self.highlighter = AbacusHighlighter(self.editor.document()) self.completion_model = QStringListModel() self.completer = QCompleter(self.completion_model, self) self.editor.set_completer(self.completer) self.editor.setPlainText( "# Example\n" "alpha = 10\n" "beta = 2\n" "alpha + beta * 3\n" ) workspace_layout.addWidget(self.editor, 1) bottom = QFrame() bottom.setObjectName("bottomCard") bottom_layout = QHBoxLayout(bottom) bottom_layout.setContentsMargins(10, 8, 10, 8) bottom_layout.addWidget(QLabel("Quick input:")) self.single_line = QLineEdit() self.single_line.setObjectName("singleLine") self.single_line.setPlaceholderText("Enter expression...") self.single_line.returnPressed.connect(self.run_single_line) bottom_layout.addWidget(self.single_line, 1) execute = QPushButton("Execute") execute.clicked.connect(self.run_single_line) bottom_layout.addWidget(execute) workspace_layout.addWidget(bottom) self.tabs.addTab(workspace, "Workspace") # Results tab results_page = QWidget() results_layout = QVBoxLayout(results_page) results_layout.setContentsMargins(10, 10, 10, 10) self.results_table = QTableWidget(0, 4) self.results_table.setHorizontalHeaderLabels( ["Step", "Expression", "Result", "Comment"] ) self.results_table.horizontalHeader().setStretchLastSection(True) self.results_table.setAlternatingRowColors(False) results_layout.addWidget(self.results_table) self.tabs.addTab(results_page, "Results") # Graph tab graph_page = QWidget() graph_layout = QVBoxLayout(graph_page) graph_layout.setContentsMargins(10, 10, 10, 10) graph_title = QLabel("Numeric result history") graph_title.setStyleSheet( "font-size: 13px; font-weight: 700; color: #c9d1d9;" ) graph_layout.addWidget(graph_title) self.graph = ResultGraph() graph_layout.addWidget(self.graph, 1) graph_info = QLabel( "The graph uses numeric results from executed expressions." ) graph_info.setStyleSheet("color: #657585;") graph_layout.addWidget(graph_info) self.tabs.addTab(graph_page, "Graph") def _build_docks(self): self.variables_dock = self._make_dock( "Variables", self._make_list_widget() ) self.functions_dock = self._make_dock( "Functions", self._make_list_widget() ) self.history_dock = self._make_dock( "History", self._make_list_widget() ) self.variables_list = self.variables_dock.widget() self.functions_list = self.functions_dock.widget() self.history_list = self.history_dock.widget() self.addDockWidget(Qt.RightDockWidgetArea, self.variables_dock) self.addDockWidget(Qt.RightDockWidgetArea, self.functions_dock) self.addDockWidget(Qt.RightDockWidgetArea, self.history_dock) self.tabifyDockWidget(self.variables_dock, self.functions_dock) self.tabifyDockWidget(self.functions_dock, self.history_dock) self.variables_dock.raise_() def _make_list_widget(self): return QListWidget() def _make_dock(self, title, widget): dock = QDockWidget(title, self) dock.setAllowedAreas( Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea ) dock.setFeatures( QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable ) dock.setWidget(widget) return dock def _build_statusbar(self): self.status = QStatusBar() self.setStatusBar(self.status) self.status.showMessage("Ready") def insert_operator(self, symbol): self.editor.insertPlainText(symbol) self.editor.setFocus() def run_single_line(self): text = self.single_line.text().strip() if not text: return self._execute(text) self.single_line.clear() def run_current_line(self): cursor = self.editor.textCursor() cursor.select(cursor.LineUnderCursor) line = cursor.selectedText().strip() if line: self.editor.clear_error_lines() line_number = cursor.blockNumber() + 1 self._execute(line, line_number) def run_document(self): self.editor.clear_error_lines() lines = self.editor.toPlainText().splitlines() executed = 0 failed = 0 for line_index, line in enumerate(lines, start=1): stripped = line.strip() if not stripped or stripped.startswith("#"): continue if self._execute(stripped, line_index): executed += 1 else: failed += 1 if failed: self.status.showMessage( f"Executed {executed} command(s), {failed} error(s)" ) else: self.status.showMessage(f"Executed {executed} command(s)") def _execute(self, text, line_number=None): self.step += 1 try: result = self.core.parse_input(text) except Exception as exc: self.step -= 1 self.status.showMessage(f"Error: {exc}") if line_number is not None: self.editor.set_error_lines({ line_number: str(exc), }) self.history_list.addItem( f"ERROR line {line_number or '?'} {text} → {exc}" ) return False # Critical: assignments/functions return None, but their # dictionaries are already changed. Refresh immediately. self._refresh_sidebars() if not result: self.status.showMessage(f"Updated: {text}") return True original, _, value, comment = result value_text = str(value) comment_text = comment or "" self.results.append( { "step": self.step, "expression": original, "result": value_text, "comment": comment_text, } ) self.history_list.addItem( f"{self.step}. {original} = {value_text}" ) self.history_list.scrollToBottom() self._add_result_row( self.step, original, value_text, comment_text ) try: numeric = float(value) except (TypeError, ValueError): numeric = None if numeric is not None and math.isfinite(numeric): self.graph.values.append(numeric) self.graph.set_values(self.graph.values) self.status.showMessage(f"Result: {value_text}") return True def _add_result_row(self, step, expression, result, comment): row = self.results_table.rowCount() self.results_table.insertRow(row) values = [str(step), expression, result, comment] for col, value in enumerate(values): self.results_table.setItem( row, col, QTableWidgetItem(value) ) self.results_table.resizeColumnsToContents() self.results_table.scrollToBottom() def _refresh_sidebars(self): self.variables_list.clear() for key, value in self.core.get_vars().items(): self.variables_list.addItem(f"{key} = {value}") self.functions_list.clear() functions = self.core.get_funcs() for key, value in functions.items(): self.functions_list.addItem(f"{key} = {value}") names = list(self.core.get_vars().keys()) + list(functions.keys()) self.completion_model.setStringList(sorted(set(names))) def export_history(self): path, _ = QFileDialog.getSaveFileName( self, "Export history", "abacus_history.json", "JSON files (*.json)", ) if not path: return data = { "format": "abacus-history", "version": 1, "step": self.step, "document": self.editor.toPlainText(), "variables": self.core.get_vars(), "functions": self.core.get_funcs(), "history": [ self.history_list.item(i).text() for i in range(self.history_list.count()) ], "results": self.results, "graph_values": self.graph.values, } try: with open(path, "w", encoding="utf-8") as file: json.dump( data, file, ensure_ascii=False, indent=2, ) except OSError as exc: self.status.showMessage(f"Export error: {exc}") return self.status.showMessage(f"History exported: {path}") def import_history(self): path, _ = QFileDialog.getOpenFileName( self, "Import history", "", "JSON files (*.json)", ) if not path: return try: with open(path, "r", encoding="utf-8") as file: data = json.load(file) if data.get("format") != "abacus-history": raise ValueError("Not an Abacus history file") if data.get("version") != 1: raise ValueError( f"Unsupported history version: {data.get('version')}" ) history = data.get("history", []) results = data.get("results", []) graph_values = data.get("graph_values", []) variables = data.get("variables", {}) functions = data.get("functions", {}) document = data.get("document", "") step = int(data.get("step", len(results))) if not isinstance(history, list): raise ValueError("Invalid history data") if not isinstance(results, list): raise ValueError("Invalid results data") if not all(isinstance(item, dict) for item in results): raise ValueError("Invalid result item") if not isinstance(graph_values, list): raise ValueError("Invalid graph data") if not isinstance(variables, dict): raise ValueError("Invalid variables data") if not isinstance(functions, dict): raise ValueError("Invalid functions data") if not isinstance(document, str): raise ValueError("Invalid document data") imported_graph_values = [] for value in graph_values: number = float(value) if not math.isfinite(number): raise ValueError("Graph contains a non-finite value") imported_graph_values.append(number) except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc: self.status.showMessage(f"Import error: {exc}") return self.step = step self.results = results self.core = AbacusCore() self.core.get_vars().clear() self.core.get_vars().update(variables) self.core.get_funcs().clear() self.core.get_funcs().update(functions) self.history_list.clear() self.history_list.addItems( str(item) for item in history ) self.results_table.setRowCount(0) for item in self.results: self._add_result_row( item.get("step", ""), item.get("expression", ""), item.get("result", ""), item.get("comment", ""), ) self.graph.set_values(imported_graph_values) self.editor.setPlainText(document) self.editor.clear_error_lines() self._refresh_sidebars() self.status.showMessage(f"History imported: {path}") def reset_abacus(self): self.core = AbacusCore() self.step = 0 self.results.clear() self.variables_list.clear() self.functions_list.clear() self.history_list.clear() self.results_table.setRowCount(0) self.graph.set_values([]) self.editor.clear_error_lines() self._refresh_sidebars() self.status.showMessage("Abacus state reset") def main(): app = QApplication(sys.argv) app.setStyle("Fusion") app.setStyleSheet(STYLE) window = AbacusIDE() window.show() sys.exit(app.exec()) if __name__ == "__main__": main()