AbacusNG/abacus_notebook.py

1271 lines
42 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 """
<h2>How Abacus Works</h2>
<p>
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.
</p>
<h3>Variables</h3>
<pre>anzahl_apfel = 10
gewicht_pro_apfel = 120
gesamt = anzahl_apfel * gewicht_pro_apfel</pre>
<p>Leading and trailing spaces are ignored.</p>
<h3>Expressions</h3>
<p>Arithmetic operators are <b>+</b>, <b>-</b>, <b>*</b> and <b>/</b>.
Parentheses can be used in expressions.</p>
<h3>The result Variable</h3>
<p>An ordinary expression stores its value in <b>result</b>.
The cell displays the result of its last executed line.</p>
<h3>Multiple Lines in One Cell</h3>
<pre>a = 10
b = 20
c = a + b
c * 2</pre>
<p>Lines are executed sequentially. Each line immediately sees
variables created by previous lines.</p>
<h3>Functions</h3>
<pre>double: alpha * 2</pre>
<p>Functions are shown in the Functions panel.</p>
<h3>Comments</h3>
<pre>alpha = 20 # working value</pre>
<h3>Variables and Functions Panels</h3>
<p>The Variables and Functions panels can be shown or hidden from
View. Double-click an item to insert its name at the cursor.</p>
<h3>Local and Global Items</h3>
<p>An unchecked item is local. A checked item belongs to the global
library and is loaded when Abacus starts.</p>
<h3>Graph</h3>
<p>The Graph tab displays selected numeric variables as bars.</p>
<h3>Running a Cell</h3>
<p>After execution, focus moves to the next cell. If there is no
next cell, Abacus creates one automatically.</p>
<h2>Keyboard Shortcuts</h2>
<table width="100%" cellspacing="8">
<tr><td><b>Shift + Enter</b></td><td>Run current cell and move to the next</td></tr>
<tr><td><b>Ctrl + Space</b></td><td>Show autocomplete</td></tr>
<tr><td><b>F5</b></td><td>Run all cells</td></tr>
<tr><td><b>Ctrl + N</b></td><td>New notebook</td></tr>
<tr><td><b>Ctrl + S</b></td><td>Export notebook</td></tr>
<tr><td><b>Ctrl + O</b></td><td>Import notebook</td></tr>
<tr><td><b>Ctrl + Q</b></td><td>Exit Abacus</td></tr>
<tr><td><b>F1</b></td><td>Open Help</td></tr>
</table>
"""
class AbacusNotebook(QMainWindow):
def __init__(self):
super().__init__()
self.core = AbacusCore()
self.cells = []
self.current_cell = None
self.graph_points = []
self.global_library_path = (
Path(__file__).resolve().with_name("abacus_globals.json")
)
self.global_variables = {}
self.global_functions = {}
self._updating_panels = False
self._load_global_library()
self.setWindowTitle("Abacus Notebook")
icon_path = Path(__file__).resolve().parent / "assets" / "abacus_icon.ico"
if icon_path.exists():
self.setWindowIcon(QIcon(str(icon_path)))
self.resize(1400, 850)
self.setMinimumSize(1000, 650)
self._build_menu()
self._build_ui()
self._build_sidebars()
self.add_cell()
self._refresh_sidebars()
self._refresh_graph_variables()
def _load_global_library(self):
if not self.global_library_path.exists():
return
try:
with self.global_library_path.open(
"r", encoding="utf-8"
) as file:
data = json.load(file)
variables = data.get("variables", {})
functions = data.get("functions", {})
if isinstance(variables, dict):
self.global_variables = variables
if isinstance(functions, dict):
self.global_functions = functions
self.core.get_vars().update(self.global_variables)
self.core.get_funcs().update(self.global_functions)
except (OSError, ValueError, TypeError, json.JSONDecodeError):
self.global_variables = {}
self.global_functions = {}
def _save_global_library(self):
data = {
"format": "abacus-global-library",
"version": 1,
"variables": self.global_variables,
"functions": self.global_functions,
}
try:
with self.global_library_path.open(
"w", encoding="utf-8"
) as file:
json.dump(
data,
file,
ensure_ascii=False,
indent=2,
)
except (OSError, TypeError, ValueError):
self.statusBar().showMessage(
f"Cannot save global library: {self.global_library_path}"
)
def _set_global_item(self, kind, name, checked):
if self._updating_panels:
return
if kind == "variable":
source = self.core.get_vars()
target = self.global_variables
else:
source = self.core.get_funcs()
target = self.global_functions
if name not in source:
return
if checked:
target[name] = source[name]
else:
target.pop(name, None)
self._save_global_library()
def _insert_panel_item(self, item):
if item is None:
return
name = item.data(Qt.UserRole)
if not name:
return
cell = self.current_cell
if cell is None or cell not in self.cells:
if self.cells:
cell = self.cells[0]
else:
self.add_cell()
cell = self.cells[0]
editor = cell.editor
editor.setFocus()
cursor = editor.textCursor()
cursor.insertText(str(name))
editor.setTextCursor(cursor)
def eventFilter(self, watched, event):
if event.type() == QEvent.FocusIn:
for cell in self.cells:
if watched is cell.editor:
self.current_cell = cell
break
return super().eventFilter(watched, event)
def _build_menu(self):
menu = self.menuBar()
file_menu = menu.addMenu("File")
new_action = file_menu.addAction("New Notebook")
new_action.setShortcut("Ctrl+N")
new_action.triggered.connect(self.new_notebook)
file_menu.addSeparator()
export_action = file_menu.addAction("Export Notebook...")
export_action.setShortcut("Ctrl+S")
export_action.triggered.connect(self.export_notebook)
import_action = file_menu.addAction("Import Notebook...")
import_action.setShortcut("Ctrl+O")
import_action.triggered.connect(self.import_notebook)
file_menu.addSeparator()
exit_action = file_menu.addAction("Exit")
exit_action.setShortcut("Ctrl+Q")
exit_action.triggered.connect(self.close)
def _build_view_menu(self):
view_menu = self.menuBar().addMenu("View")
variables_action = self.variables_dock.toggleViewAction()
variables_action.setText("Variables")
view_menu.addAction(variables_action)
functions_action = self.functions_dock.toggleViewAction()
functions_action.setText("Functions")
view_menu.addAction(functions_action)
view_menu.addSeparator()
refresh_action = view_menu.addAction("Refresh panels")
refresh_action.triggered.connect(self._refresh_all_panels)
view_menu.addSeparator()
library_action = view_menu.addAction("Global library file")
library_action.triggered.connect(
lambda: self.statusBar().showMessage(
str(self.global_library_path)
)
)
spacer = QWidget()
spacer.setSizePolicy(
QSizePolicy.Expanding,
QSizePolicy.Preferred,
)
spacer_action = QWidgetAction(self.menuBar())
spacer_action.setDefaultWidget(spacer)
self.menuBar().addAction(spacer_action)
help_menu = self.menuBar().addMenu("Help")
help_action = help_menu.addAction("Abacus Help")
help_action.setShortcut("F1")
help_action.triggered.connect(self._show_help)
def _show_help(self):
HelpDialog(self).exec()
def _build_ui(self):
central = QWidget()
root = QVBoxLayout(central)
root.setContentsMargins(10, 10, 10, 10)
toolbar = QHBoxLayout()
title = QLabel("Abacus Notebook")
title.setStyleSheet("font-size:18px;font-weight:700;color:#f0f6fc;")
toolbar.addWidget(title)
toolbar.addStretch()
run_all = QPushButton("▶ Run All")
run_all.setToolTip("Run all cells (F5)")
run_all.clicked.connect(self.run_all)
run_all.setShortcut("F5")
toolbar.addWidget(run_all)
add = QPushButton("+ Cell")
add.clicked.connect(self._add_cell_from_button)
toolbar.addWidget(add)
root.addLayout(toolbar)
self.tabs = QTabWidget()
notebook_page = QWidget()
notebook_layout = QVBoxLayout(notebook_page)
notebook_layout.setContentsMargins(0, 0, 0, 0)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.notebook_widget = QWidget()
self.notebook_layout = QVBoxLayout(self.notebook_widget)
self.notebook_layout.setContentsMargins(8, 8, 8, 8)
self.notebook_layout.setSpacing(7)
self.notebook_layout.addStretch()
self.scroll.setWidget(self.notebook_widget)
notebook_layout.addWidget(self.scroll)
self.tabs.addTab(notebook_page, "Notebook")
graph_page = QWidget()
graph_layout = QVBoxLayout(graph_page)
graph_layout.setContentsMargins(10, 10, 10, 10)
graph_header = QHBoxLayout()
graph_header.addWidget(QLabel("Variables"))
graph_header.addStretch()
select_all = QPushButton("All")
select_all.setObjectName("small")
select_all.clicked.connect(lambda: self._set_all_graph_variables(True))
graph_header.addWidget(select_all)
clear_all = QPushButton("None")
clear_all.setObjectName("small")
clear_all.clicked.connect(lambda: self._set_all_graph_variables(False))
graph_header.addWidget(clear_all)
graph_layout.addLayout(graph_header)
graph_body = QHBoxLayout()
self.graph_variables = QWidget()
self.graph_variables_layout = QVBoxLayout(self.graph_variables)
self.graph_variables_layout.setContentsMargins(0, 0, 10, 0)
self.graph_variables_layout.setSpacing(4)
graph_scroll = QScrollArea()
graph_scroll.setWidgetResizable(True)
graph_scroll.setFixedWidth(240)
graph_scroll.setWidget(self.graph_variables)
graph_body.addWidget(graph_scroll)
self.graph = GraphWidget()
graph_body.addWidget(self.graph, 1)
graph_layout.addLayout(graph_body, 1)
self.tabs.addTab(graph_page, "Graph")
root.addWidget(self.tabs, 1)
self.setCentralWidget(central)
self.statusBar().showMessage("Ready")
def _refresh_graph_variables(self):
old_checked = {
checkbox.text(): checkbox.isChecked()
for checkbox in self.graph_variables.findChildren(QCheckBox)
}
while self.graph_variables_layout.count():
item = self.graph_variables_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
variables = dict(self.core.get_vars())
for name, value in variables.items():
if not isinstance(value, (int, float)) or isinstance(value, bool):
continue
checkbox = QCheckBox(name)
checkbox.setChecked(old_checked.get(name, True))
checkbox.toggled.connect(self._update_graph_from_selection)
self.graph_variables_layout.addWidget(checkbox)
self.graph_variables_layout.addStretch()
self._update_graph_from_selection()
def _update_graph_from_selection(self):
selected = set()
for checkbox in self.graph_variables.findChildren(QCheckBox):
if checkbox.isChecked():
selected.add(checkbox.text())
variables = dict(self.core.get_vars())
values = {
name: value
for name, value in variables.items()
if name in selected
}
self.graph.set_variables(values)
def _set_all_graph_variables(self, checked):
for checkbox in self.graph_variables.findChildren(QCheckBox):
checkbox.setChecked(checked)
def _sync_global_library_from_core(self):
variables = self.core.get_vars()
functions = self.core.get_funcs()
changed = False
for name in list(self.global_variables):
if name in variables and self.global_variables[name] != variables[name]:
self.global_variables[name] = variables[name]
changed = True
elif name not in variables:
self.global_variables.pop(name, None)
changed = True
for name in list(self.global_functions):
if name in functions and self.global_functions[name] != functions[name]:
self.global_functions[name] = functions[name]
changed = True
elif name not in functions:
self.global_functions.pop(name, None)
changed = True
if changed:
self._save_global_library()
def _refresh_all_panels(self):
self._refresh_sidebars()
self._update_completion()
self._refresh_graph_variables()
self.statusBar().showMessage("Panels refreshed")
def _make_panel_item(self, name, value, kind):
item = QListWidgetItem()
item.setData(Qt.UserRole, name)
item.setData(Qt.UserRole + 1, kind)
item.setText(f"{name} = {value}" if kind == "variable"
else f"{name} : {value}")
item.setFlags(
item.flags()
| Qt.ItemIsUserCheckable
| Qt.ItemIsSelectable
| Qt.ItemIsEnabled
)
global_values = (
self.global_variables
if kind == "variable"
else self.global_functions
)
item.setCheckState(
Qt.Checked if name in global_values else Qt.Unchecked
)
return item
def _refresh_sidebars(self):
variables = dict(self.core.get_vars())
functions = dict(self.core.get_funcs())
self._updating_panels = True
self.variables_list.blockSignals(True)
self.functions_list.blockSignals(True)
try:
self.variables_list.clear()
for name, value in variables.items():
self.variables_list.addItem(
self._make_panel_item(name, value, "variable")
)
if not variables:
empty = QListWidgetItem("(no variables)")
empty.setForeground(QColor("#718096"))
self.variables_list.addItem(empty)
self.functions_list.clear()
for name, value in functions.items():
self.functions_list.addItem(
self._make_panel_item(name, value, "function")
)
if not functions:
empty = QListWidgetItem("(no functions)")
empty.setForeground(QColor("#718096"))
self.functions_list.addItem(empty)
finally:
self.variables_list.blockSignals(False)
self.functions_list.blockSignals(False)
self._updating_panels = False
def _add_cell_from_button(self, checked=False):
self.add_cell()
@staticmethod
def _style_panel_list(widget):
palette = widget.palette()
palette.setColor(QPalette.Base, QColor("#0d131a"))
palette.setColor(QPalette.Text, QColor("#e6edf3"))
palette.setColor(QPalette.Button, QColor("#263342"))
palette.setColor(QPalette.ButtonText, QColor("#e6edf3"))
palette.setColor(QPalette.Highlight, QColor("#1d4f85"))
palette.setColor(QPalette.HighlightedText, QColor("#ffffff"))
widget.setPalette(palette)
def _build_sidebars(self):
self.variables_dock = QDockWidget("Variables", self)
self.variables_list = QListWidget()
self._style_panel_list(self.variables_list)
self.variables_list.itemChanged.connect(
self._variable_item_changed
)
self.variables_list.itemDoubleClicked.connect(
self._insert_panel_item
)
self.variables_dock.setWidget(self.variables_list)
self.variables_dock.setMinimumWidth(260)
self.variables_dock.setAllowedAreas(
Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea
)
self.addDockWidget(Qt.RightDockWidgetArea, self.variables_dock)
self.functions_dock = QDockWidget("Functions", self)
self.functions_list = QListWidget()
self._style_panel_list(self.functions_list)
self.functions_list.itemChanged.connect(
self._function_item_changed
)
self.functions_list.itemDoubleClicked.connect(
self._insert_panel_item
)
self.functions_dock.setWidget(self.functions_list)
self.functions_dock.setMinimumWidth(260)
self.functions_dock.setAllowedAreas(
Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea
)
self.addDockWidget(Qt.RightDockWidgetArea, self.functions_dock)
self.resizeDocks(
[self.variables_dock, self.functions_dock],
[300, 300],
Qt.Vertical,
)
self.variables_dock.show()
self.functions_dock.show()
self._build_view_menu()
def _variable_item_changed(self, item):
if item is not None:
self._set_global_item(
"variable",
item.data(Qt.UserRole),
item.checkState() == Qt.Checked,
)
def _function_item_changed(self, item):
if item is not None:
self._set_global_item(
"function",
item.data(Qt.UserRole),
item.checkState() == Qt.Checked,
)
def add_cell(self, after=None, code=""):
cell = NotebookCell(self, code=code)
index = len(self.cells) if after is None else self.cells.index(after) + 1
self.cells.insert(index, cell)
self.notebook_layout.insertWidget(index, cell)
cell.editor.installEventFilter(self)
self.current_cell = cell
self._renumber()
self._update_completion()
cell.editor.setFocus()
def add_cell_after(self, cell):
self.add_cell(cell)
def delete_cell(self, cell):
if len(self.cells) == 1:
cell.editor.clear()
cell.clear_output()
return
index = self.cells.index(cell)
self.cells.remove(cell)
cell.setParent(None)
cell.deleteLater()
self._renumber()
self._rebuild_graph()
self._update_completion()
self._refresh_graph_variables()
if index < len(self.cells):
self.cells[index].editor.setFocus()
def _renumber(self):
for index, cell in enumerate(self.cells, 1):
cell.set_number(index)
def _update_completion(self):
names = list(self.core.get_vars()) + list(self.core.get_funcs())
for cell in self.cells:
cell.set_completion_names(names)
def run_cell(self, cell, advance=True):
code = cell.editor.toPlainText().strip()
if not code:
if advance:
self._focus_next_cell(cell)
return
try:
self.core.parse_input(code)
# Always read the live dictionaries from the core after execution.
# The GUI never keeps a second copy of variables/functions.
variables = dict(self.core.get_vars())
functions = dict(self.core.get_funcs())
result_value = variables.get("result")
except Exception as exc:
cell.show_error(exc)
self._refresh_sidebars()
self._refresh_graph_variables()
self.statusBar().showMessage(
f"Error in In [{cell.number}]: {exc}"
)
return
cell.show_result(result_value)
# The Graph tab is based on the currently selected variables.
self._refresh_sidebars()
self._refresh_graph_variables()
self._sync_global_library_from_core()
self.statusBar().showMessage(
f"In [{cell.number}] result = {result_value}"
)
if advance:
self._focus_next_cell(cell)
def _focus_next_cell(self, cell):
index = self.cells.index(cell)
if index + 1 < len(self.cells):
next_cell = self.cells[index + 1]
else:
self.add_cell()
next_cell = self.cells[-1]
next_cell.editor.setFocus()
next_cell.editor.moveCursor(QTextCursor.End)
# Keep the newly focused cell visible in the notebook.
self.scroll.ensureWidgetVisible(next_cell)
def run_all(self):
cells = list(self.cells)
for cell in cells:
if cell.editor.toPlainText().strip():
self.run_cell(cell, advance=False)
self.statusBar().showMessage("Notebook executed")
def _rebuild_graph(self):
# GraphWidget uses the current variable/bar API.
self._refresh_graph_variables()
def new_notebook(self):
self.core = AbacusCore()
self.core.get_vars().update(self.global_variables)
self.core.get_funcs().update(self.global_functions)
for cell in self.cells:
cell.setParent(None)
cell.deleteLater()
self.cells.clear()
self.graph_points.clear()
self.graph.set_variables({})
self.variables_list.clear()
self.functions_list.clear()
self._refresh_graph_variables()
self.add_cell()
self._refresh_sidebars()
self.statusBar().showMessage("New notebook")
def export_notebook(self):
path, _ = QFileDialog.getSaveFileName(
self, "Export Notebook", "abacus_notebook.json",
"Abacus Notebook (*.json)",
)
if not path:
return
data = {
"format": "abacus-notebook",
"version": 1,
"variables": self.core.get_vars(),
"functions": self.core.get_funcs(),
"cells": [c.to_dict() for c in self.cells],
"graph": [
{"step": step, "result": value}
for step, value in self.graph_points
],
}
try:
with open(path, "w", encoding="utf-8") as file:
json.dump(data, file, ensure_ascii=False, indent=2)
except OSError as exc:
self.statusBar().showMessage(f"Export error: {exc}")
return
self.statusBar().showMessage(f"Notebook exported: {path}")
def import_notebook(self):
path, _ = QFileDialog.getOpenFileName(
self, "Import Notebook", "", "Abacus Notebook (*.json)",
)
if not path:
return
try:
with open(path, "r", encoding="utf-8") as file:
data = json.load(file)
if data.get("format") != "abacus-notebook":
raise ValueError("Not an Abacus Notebook file")
if data.get("version") != 1:
raise ValueError("Unsupported notebook version")
variables = data.get("variables", {})
functions = data.get("functions", {})
cells = data.get("cells", [])
if not isinstance(variables, dict) or not isinstance(functions, dict):
raise ValueError("Invalid variables/functions")
if not isinstance(cells, list):
raise ValueError("Invalid cells")
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
self.statusBar().showMessage(f"Import error: {exc}")
return
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)
for cell in self.cells:
cell.setParent(None)
cell.deleteLater()
self.cells.clear()
for item in cells:
cell = NotebookCell(self, code=str(item.get("code", "")))
if item.get("error"):
cell.show_error(item["error"])
elif item.get("result") is not None:
cell.show_result(item["result"])
self.cells.append(cell)
self.notebook_layout.insertWidget(len(self.cells) - 1, cell)
if not self.cells:
self.add_cell()
self._renumber()
self._update_completion()
self._refresh_sidebars()
self._refresh_graph_variables()
self._rebuild_graph()
self.statusBar().showMessage(f"Notebook imported: {path}")
def main():
app = QApplication(sys.argv)
app.setStyle("Fusion")
app.setStyleSheet(STYLE)
icon_path = Path(__file__).resolve().parent / "assets" / "abacus_icon.ico"
if icon_path.exists():
app.setWindowIcon(QIcon(str(icon_path)))
window = AbacusNotebook()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()