1092 lines
30 KiB
Python
1092 lines
30 KiB
Python
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()
|