From 1d2100628186f526f83b3565e4be5c325de68ac9 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 12:03:07 -0400 Subject: [PATCH] feat: console log dock --- src/otko/views/docks/console.py | 108 ++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 src/otko/views/docks/console.py diff --git a/src/otko/views/docks/console.py b/src/otko/views/docks/console.py new file mode 100644 index 0000000..e00308c --- /dev/null +++ b/src/otko/views/docks/console.py @@ -0,0 +1,108 @@ +# Ported from otko-development (MIT), (c) 2026 OTKO contributors +"""The console dock: the timestamped analysis log.""" + +from __future__ import annotations + +from datetime import datetime + +from PySide6.QtGui import QFont, QFontDatabase +from PySide6.QtWidgets import ( + QApplication, + QDockWidget, + QHBoxLayout, + QPlainTextEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + +__all__ = ["ConsoleDock"] + +#: Recognised severities; anything else is treated as information. +_SEVERITIES = ("info", "warning", "error", "success") + + +class ConsoleDock(QDockWidget): + """Show analysis log lines with a timestamp and severity label. + + The dock never decides anything about the model: it only renders text the + viewmodel or worker emits. Lines are appended, so the log is a faithful + running record of the session. + """ + + def __init__(self, parent: QWidget | None = None) -> None: + """Create the console dock with a read-only, monospaced log view.""" + super().__init__("Console", parent) + self.setObjectName("consoleDock") + + self._view = QPlainTextEdit(self) + self._view.setReadOnly(True) + self._view.setObjectName("consoleView") + self._view.setMaximumBlockCount(5000) + self._view.setPlaceholderText("Logs and OpenSeesPy output will appear here.") + self._view.setFont(self._monospace_font()) + + clear_button = QPushButton("Clear", self) + clear_button.setToolTip("Clear the console log") + clear_button.setStatusTip("Clear the console log") + clear_button.clicked.connect(self.clear) + copy_button = QPushButton("Copy All", self) + copy_button.setToolTip("Copy the whole log to the clipboard") + copy_button.setStatusTip("Copy the whole log to the clipboard") + copy_button.clicked.connect(self._copy_all) + + buttons = QHBoxLayout() + buttons.addStretch(1) + buttons.addWidget(copy_button) + buttons.addWidget(clear_button) + + layout = QVBoxLayout() + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(4) + layout.addWidget(self._view, 1) + layout.addLayout(buttons) + + container = QWidget(self) + container.setLayout(layout) + self.setWidget(container) + + # -- API ----------------------------------------------------------- + + def log(self, message: str, severity: str = "info") -> None: + """Append ``message`` with a timestamp and ``severity`` tag. + + Every line carries a wall-clock time and a severity so a failure is + easy to spot after a run. + """ + tag = severity if severity in _SEVERITIES else "info" + stamp = datetime.now().strftime("%H:%M:%S") + self._view.appendPlainText(f"{stamp} [{tag.upper():<7}] {message}") + + def clear(self) -> None: + """Remove every line from the log.""" + self._view.clear() + + @property + def text(self) -> str: + """Return the full log text (used by tests and copy-all).""" + return self._view.toPlainText() + + @property + def view(self) -> QPlainTextEdit: + """Return the inner text view (for legacy append-only call sites).""" + return self._view + + # -- Internals ----------------------------------------------------- + + def _copy_all(self) -> None: + """Copy the whole log to the clipboard.""" + clipboard = QApplication.clipboard() + if clipboard is not None: + clipboard.setText(self.text) + + @staticmethod + def _monospace_font() -> QFont: + """Return a fixed-width font so timestamps align.""" + font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + font.setPointSize(10) + return font