979b69960f
On OK the dialog closed and nothing appeared until the figures did, which on a real board is minutes of looking like the plugin did nothing. Put a small always-on-top window up for that stretch: the stage now running, elapsed seconds, and Cancel. Qt only repaints while the event loop runs and the solve owns the thread, so the window pumps events itself - from inside the CG/AMG iteration callback, which is where the time actually goes. That is also where Cancel is noticed. The state is module-level because the tick happens several frames deep in scipy/pyamg, and threading a handle through those signatures for a progress bar is not worth it; it stays inert until start(), so the standalone runner and the tests are unaffected. The window covers the figure work too, not just the solve: laying out labels and writing four PNGs at full DPI is seconds on a real board - 10-15 of them on a large one - and closing before that left the same silent gap one step later.
146 lines
4.3 KiB
Python
146 lines
4.3 KiB
Python
"""Busy window for the stretch between the dialog closing and the
|
|
figures appearing.
|
|
|
|
The solve is seconds to minutes on a real board, and until now nothing
|
|
was on screen for it: the dialog vanished on OK and the plugin looked
|
|
like it had done nothing. This puts a small always-on-top window up for
|
|
that stretch - current stage, elapsed time, and a Cancel button.
|
|
|
|
The state is module-level rather than an object threaded through the
|
|
call chain: the linear solve is where the time actually goes, and it
|
|
calls tick() from inside a scipy/pyamg iteration callback several
|
|
frames deep. Inactive until start() succeeds, so every call is a no-op
|
|
for the standalone runner and the tests.
|
|
|
|
Qt only repaints when the event loop runs, and the solve owns the
|
|
thread, so tick() pumps events itself. That is also where a click on
|
|
Cancel is noticed - it raises Cancelled at the next tick.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
_win = None
|
|
_label = None
|
|
_text = ""
|
|
_t0 = 0.0
|
|
_last = 0.0
|
|
_cancelled = False
|
|
|
|
TICK_INTERVAL_S = 0.05 # ~20 fps: enough to look alive, cheap
|
|
|
|
|
|
class Cancelled(Exception):
|
|
"""The user closed the progress window. Not a failure - the caller
|
|
reports it like a cancelled dialog, with no error figure."""
|
|
|
|
|
|
def start(title: str = "Fill Resistance") -> bool:
|
|
"""Show the window. False (and inert) if Qt is unavailable."""
|
|
global _win, _label, _t0, _last, _cancelled, _text
|
|
if _win is not None:
|
|
return True
|
|
try:
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtWidgets import (QApplication, QDialog,
|
|
QDialogButtonBox, QLabel,
|
|
QProgressBar, QVBoxLayout)
|
|
except Exception:
|
|
return False
|
|
try:
|
|
app = QApplication.instance() or QApplication([])
|
|
win = QDialog()
|
|
win.setWindowTitle(title)
|
|
win.setWindowFlag(Qt.WindowStaysOnTopHint, True)
|
|
# no close button: closing is Cancel, and Cancel is the only way
|
|
# to stop a solve that owns the thread
|
|
win.setWindowFlag(Qt.WindowCloseButtonHint, False)
|
|
|
|
label = QLabel("starting ...")
|
|
bar = QProgressBar()
|
|
bar.setRange(0, 0) # indeterminate: no total to show
|
|
buttons = QDialogButtonBox(QDialogButtonBox.Cancel)
|
|
|
|
layout = QVBoxLayout()
|
|
layout.addWidget(label)
|
|
layout.addWidget(bar)
|
|
layout.addWidget(buttons)
|
|
win.setLayout(layout)
|
|
|
|
buttons.rejected.connect(_cancel)
|
|
win.rejected.connect(_cancel)
|
|
win.setMinimumWidth(340)
|
|
win.show()
|
|
win.raise_()
|
|
win.activateWindow()
|
|
app.processEvents()
|
|
except Exception:
|
|
return False
|
|
|
|
_win, _label, _t0, _last, _cancelled, _text = win, label, \
|
|
time.monotonic(), 0.0, False, ""
|
|
return True
|
|
|
|
|
|
def _cancel() -> None:
|
|
global _cancelled
|
|
_cancelled = True
|
|
|
|
|
|
def stage(text: str, echo: bool = True) -> None:
|
|
"""Name the phase now running. Always repaints - stages are rare.
|
|
|
|
echo=False for phases that already print their own line (saving a
|
|
PNG prints the path), so the window updates without doubling stdout.
|
|
"""
|
|
global _text
|
|
_text = text
|
|
if echo:
|
|
print(text)
|
|
if _win is not None:
|
|
_refresh()
|
|
|
|
|
|
def tick() -> None:
|
|
"""Called from inside the solve. Throttled, so it is safe to call
|
|
every iteration."""
|
|
global _last
|
|
if _win is None:
|
|
return
|
|
now = time.monotonic()
|
|
if now - _last < TICK_INTERVAL_S:
|
|
return
|
|
_last = now
|
|
_refresh()
|
|
|
|
|
|
def _refresh() -> None:
|
|
from PySide6.QtWidgets import QApplication
|
|
|
|
elapsed = time.monotonic() - _t0
|
|
if _label is not None:
|
|
_label.setText(f"{_text}\n{elapsed:.0f} s elapsed")
|
|
app = QApplication.instance()
|
|
if app is not None:
|
|
app.processEvents()
|
|
if _cancelled:
|
|
raise Cancelled()
|
|
|
|
|
|
def done() -> None:
|
|
"""Take the window down. Idempotent - callers use it in a finally."""
|
|
global _win, _label, _text, _cancelled
|
|
win, _win, _label, _text = _win, None, None, ""
|
|
_cancelled = False
|
|
if win is None:
|
|
return
|
|
try:
|
|
win.close()
|
|
win.deleteLater()
|
|
from PySide6.QtWidgets import QApplication
|
|
app = QApplication.instance()
|
|
if app is not None:
|
|
app.processEvents()
|
|
except Exception:
|
|
pass
|