Skip to content

Commit b13f8b7

Browse files
authored
feat: Core log widget (#429)
1 parent ca31061 commit b13f8b7

File tree

5 files changed

+289
-1
lines changed

5 files changed

+289
-1
lines changed

docs/widget_list.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"ChannelWidget",
4040
"ImagePreview",
4141
"SnapButton",
42-
"LiveButton"
42+
"LiveButton",
43+
"CoreLogWidget"
4344
]
4445
}

examples/core_log_widget.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from qtpy.QtWidgets import QApplication
2+
3+
from pymmcore_widgets import CoreLogWidget
4+
5+
app = QApplication([])
6+
wdg = CoreLogWidget()
7+
wdg.show()
8+
app.exec()

src/pymmcore_widgets/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"ChannelWidget",
1717
"ConfigWizard",
1818
"ConfigurationWidget",
19+
"CoreLogWidget",
1920
"DefaultCameraExposureWidget",
2021
"DeviceWidget",
2122
"ExposureWidget",
@@ -45,6 +46,7 @@
4546
]
4647

4748
from ._install_widget import InstallWidget
49+
from ._log import CoreLogWidget
4850
from .config_presets import (
4951
GroupPresetTableWidget,
5052
ObjectivesPixelConfigurationWidget,

src/pymmcore_widgets/_log.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from collections import deque
5+
from typing import TYPE_CHECKING
6+
7+
from pymmcore_plus import CMMCorePlus
8+
from qtpy.QtCore import QFileSystemWatcher, QObject, QTimer, QUrl, Signal
9+
from qtpy.QtGui import QCloseEvent, QDesktopServices, QFontDatabase, QPalette
10+
from qtpy.QtWidgets import (
11+
QApplication,
12+
QHBoxLayout,
13+
QPlainTextEdit,
14+
QPushButton,
15+
QSizePolicy,
16+
QVBoxLayout,
17+
QWidget,
18+
)
19+
from superqt import QElidingLabel, QIconifyIcon
20+
21+
if TYPE_CHECKING:
22+
from io import TextIOWrapper
23+
24+
25+
class _LogReader(QObject):
26+
"""Watches a log file and emits new lines as they arrive."""
27+
28+
new_lines: Signal = Signal(str)
29+
finished: Signal = Signal()
30+
31+
def __init__(
32+
self,
33+
path: str,
34+
interval: int = 200,
35+
parent: QObject | None = None,
36+
) -> None:
37+
super().__init__(parent)
38+
self._path = path
39+
self._interval = interval
40+
self._file: TextIOWrapper | None = None
41+
42+
# Unfortunately, on Windows, QFileSystemWatcher does not detect file changes
43+
# unless the file is flushed from cache to disk. This does NOT happen
44+
# when CMMCorePlus.logMessage() is called. So we need to poll the file for
45+
# Windows' sake.
46+
self._timer = QTimer(self)
47+
self._timer.setInterval(self._interval)
48+
self._timer.timeout.connect(self._read_new)
49+
50+
# Watcher for rotation/truncate events
51+
self._watcher = QFileSystemWatcher(self)
52+
self._watcher.addPath(self._path)
53+
self._watcher.fileChanged.connect(self._on_file_changed)
54+
55+
def start(self) -> None:
56+
"""Open the file and start polling."""
57+
self._file = open(self._path, encoding="utf-8", errors="replace")
58+
self._file.seek(0, os.SEEK_END)
59+
self._timer.start()
60+
61+
def _stop(self) -> None:
62+
"""Stop polling and close the file."""
63+
self._timer.stop()
64+
if self._file:
65+
self._file.close()
66+
self.finished.emit()
67+
68+
def _on_file_changed(self, path: str) -> None:
69+
"""Handle log rotation or truncation."""
70+
try:
71+
real_size = os.path.getsize(path)
72+
current_pos = self._file.tell() if self._file else 0
73+
if real_size < current_pos:
74+
# rotated or truncated
75+
if self._file:
76+
self._file.close()
77+
self._file = open(self._path, encoding="utf-8", errors="replace")
78+
self._read_new()
79+
except Exception:
80+
pass
81+
82+
def _read_new(self) -> None:
83+
"""Read and emit any new lines."""
84+
if not self._file:
85+
return
86+
for line in self._file:
87+
self.new_lines.emit(line.rstrip("\n"))
88+
89+
90+
class CoreLogWidget(QWidget):
91+
"""High-performance log console with pause, follow-tail, clear, and initial load."""
92+
93+
def __init__(
94+
self,
95+
path: str | None = None,
96+
max_lines: int = 5_000,
97+
parent: QWidget | None = None,
98+
mmcore: CMMCorePlus | None = None,
99+
) -> None:
100+
super().__init__(parent)
101+
self._mmcore = mmcore or CMMCorePlus().instance()
102+
self.setWindowTitle("Log Console")
103+
104+
# --- Log path ---
105+
self._log_path = QElidingLabel()
106+
self._log_path.setSizePolicy(
107+
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum
108+
)
109+
110+
self._clear_btn = QPushButton("Clear Display")
111+
self._clear_btn.setToolTip(
112+
"Clears this view. Does not delete lines from the log file."
113+
)
114+
115+
self._log_btn = QPushButton()
116+
color = QApplication.palette().color(QPalette.ColorRole.WindowText).name()
117+
self._log_btn.setIcon(QIconifyIcon("majesticons:open", color=color))
118+
self._log_btn.setToolTip("Open log file in native editor")
119+
120+
# --- Log view ---
121+
self._log_view = QPlainTextEdit(self)
122+
self._log_view.setReadOnly(True)
123+
self._log_view.setMaximumBlockCount(max_lines)
124+
self._log_view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
125+
# Monospaced font
126+
fixed_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
127+
fixed_font.setPixelSize(12)
128+
self._log_view.setFont(fixed_font)
129+
130+
path = path or self._mmcore.getPrimaryLogFile()
131+
self._log_path.setText(path)
132+
# Load the last `max_lines` from file
133+
try:
134+
with open(path, encoding="utf-8", errors="replace") as f:
135+
for line in deque(f, maxlen=max_lines):
136+
self._log_view.appendPlainText(line.rstrip("\n"))
137+
except Exception:
138+
pass
139+
140+
# --- Reader thread setup ---
141+
self._reader = _LogReader(path)
142+
143+
# --- Layout ---
144+
file_layout = QHBoxLayout()
145+
file_layout.setContentsMargins(5, 5, 5, 0)
146+
file_layout.addWidget(self._log_path)
147+
file_layout.addWidget(self._clear_btn)
148+
file_layout.addWidget(self._log_btn)
149+
150+
layout = QVBoxLayout(self)
151+
layout.setContentsMargins(0, 0, 0, 0)
152+
layout.addLayout(file_layout)
153+
layout.addWidget(self._log_view)
154+
self.setLayout(layout)
155+
156+
# --- Connections ---
157+
self._reader.new_lines.connect(self._append_line)
158+
self._clear_btn.clicked.connect(self._log_view.clear)
159+
self._log_btn.clicked.connect(self._open_native)
160+
self._reader.start()
161+
162+
def __del__(self) -> None:
163+
"""Stop reader before deletion."""
164+
self._reader._stop()
165+
166+
def _append_line(self, line: str) -> None:
167+
"""Append a line, respecting pause/follow settings."""
168+
self._log_view.appendPlainText(line)
169+
170+
def closeEvent(self, event: QCloseEvent | None) -> None:
171+
"""Clean up thread on close."""
172+
self._reader._stop()
173+
# self._thread.quit()
174+
# self._thread.wait()
175+
super().closeEvent(event)
176+
177+
def _open_native(self) -> None:
178+
"""Open the log file in the system's default text editor."""
179+
QDesktopServices.openUrl(QUrl.fromLocalFile(self._mmcore.getPrimaryLogFile()))

tests/test_core_log_widget.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from qtpy.QtWidgets import QApplication
6+
7+
from pymmcore_widgets import CoreLogWidget
8+
9+
if TYPE_CHECKING:
10+
from pymmcore_plus import CMMCorePlus
11+
from pytestqt.qtbot import QtBot
12+
13+
14+
def test_core_log_widget_init(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None:
15+
"""Asserts that the CoreLogWidget initializes with the entire log to this point."""
16+
wdg = CoreLogWidget()
17+
qtbot.addWidget(wdg)
18+
19+
# Assert log path is in the widget LineEdit
20+
log_path = global_mmcore.getPrimaryLogFile()
21+
assert log_path == wdg._log_path.text()
22+
23+
# Assert log content is in the widget TextEdit
24+
# This is a bit tricky because more can be appended to the log file.
25+
with open(log_path) as f:
26+
log_content = [s.strip() for s in f.readlines()]
27+
# Trim down to the final 5000 lines if necessary
28+
# (this is all that will fit in the Log Widget)
29+
max_lines = wdg._log_view.maximumBlockCount()
30+
if len(log_content) > max_lines:
31+
log_content = log_content[-max_lines:]
32+
edit_content = [s.strip() for s in wdg._log_view.toPlainText().splitlines()]
33+
min_length = min(len(log_content), len(edit_content))
34+
for i in range(min_length):
35+
assert log_content[i] == edit_content[i]
36+
37+
38+
def test_core_log_widget_update(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None:
39+
wdg = CoreLogWidget()
40+
qtbot.addWidget(wdg)
41+
# Remove some lines for faster checking later
42+
wdg._log_view.clear()
43+
44+
# Log something new
45+
new_message = "Test message"
46+
global_mmcore.logMessage(new_message)
47+
48+
def wait_for_update() -> None:
49+
# Sometimes, our new message will be flushed before other initialization
50+
# completes. Thus we need to check all lines after what is currently written to
51+
# the TextEdit.
52+
all_lines = wdg._log_view.toPlainText().splitlines()
53+
for line in reversed(all_lines):
54+
if f"[IFO,App] {new_message}" in line:
55+
return
56+
raise AssertionError("New message not found in CoreLogWidget.")
57+
58+
qtbot.waitUntil(wait_for_update, timeout=1000)
59+
60+
61+
def test_core_log_widget_clear(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None:
62+
wdg = CoreLogWidget()
63+
qtbot.addWidget(wdg)
64+
65+
assert wdg._log_view.toPlainText() != ""
66+
wdg._clear_btn.click()
67+
assert wdg._log_view.toPlainText() == ""
68+
69+
70+
def test_core_log_widget_autoscroll(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None:
71+
wdg = CoreLogWidget()
72+
qtbot.addWidget(wdg)
73+
# Note that we must show the widget for the scrollbar maximum to be computed
74+
wdg.show()
75+
sb = wdg._log_view.verticalScrollBar()
76+
assert sb is not None
77+
78+
def add_new_line() -> None:
79+
wdg._append_line("Test message")
80+
QApplication.processEvents()
81+
82+
# Make sure we have a scrollbar with nonzero size to test with
83+
# But we don't want it full yet
84+
wdg._log_view.clear()
85+
while sb.maximum() == 0:
86+
add_new_line()
87+
88+
# Assert that adding a new line does not scroll if not at the bottom
89+
sb.setValue(sb.minimum())
90+
add_new_line()
91+
assert sb.value() == sb.minimum()
92+
93+
# Assert that adding a new line does scroll if at the bottom
94+
old_max = sb.maximum()
95+
sb.setValue(old_max)
96+
add_new_line()
97+
assert sb.maximum() == old_max + 1
98+
assert sb.value() == sb.maximum()

0 commit comments

Comments
 (0)