mirror of
https://github.com/crosspoint-reader/calibre-plugins.git
synced 2026-08-13 14:23:41 -07:00
190 lines
6.2 KiB
Python
190 lines
6.2 KiB
Python
"""Live optimization progress dialog (shown during transfer).
|
|||
|
|
|
||
|
|
The device driver's ``upload_books`` runs on Calibre's device-manager thread, not
|
||
|
|
the GUI thread, and Qt widgets may only be created/shown from the GUI thread.
|
||
|
|
|
||
|
|
Cross-thread delivery only works reliably when the receiving QObject was *created
|
||
|
|
on the GUI thread* (moving a worker-thread object to the GUI thread does not make
|
||
|
|
queued delivery work). So the bridge is created once, eagerly, at plugin import
|
||
|
|
time — which Calibre performs on the main thread, with the QApplication already
|
||
|
|
running — and the worker thread merely emits queued signals to it:
|
||
|
|
|
||
|
|
begin(title) -> open the dialog and show "working"
|
||
|
|
step(tag, message) -> append a line as each image/fix is processed
|
||
|
|
finish(payload) -> append the totals and enable the Close button
|
||
|
|
|
||
|
|
The dialog is modeless, so it appears as soon as optimization starts and updates
|
||
|
|
live while books are processed and uploaded.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from qt.core import QObject, QApplication, QThread, Qt, pyqtSignal
|
||
|
|
|
||
|
|
from .log import add_log
|
||
|
|
from .optimizer import _human
|
||
|
|
|
||
|
|
|
||
|
|
def _summary_lines(payload):
|
||
|
|
books = payload.get('books', [])
|
||
|
|
total_orig = sum(b['orig_size'] for b in books)
|
||
|
|
total_new = sum(b['new_size'] for b in books)
|
||
|
|
total_imgs = sum(b['images'] for b in books)
|
||
|
|
total_fixes = sum(b['fixes'] for b in books)
|
||
|
|
total_err = sum(b['errors'] for b in books)
|
||
|
|
saved = total_orig - total_new
|
||
|
|
pct = (saved / float(total_orig) * 100.0) if total_orig else 0.0
|
||
|
|
lines = [
|
||
|
|
'',
|
||
|
|
'──────────────────────────────',
|
||
|
|
'Done: %d book(s), %d image(s), %d fix(es)%s' % (
|
||
|
|
len(books), total_imgs, total_fixes,
|
||
|
|
(' %d error(s)' % total_err) if total_err else ''),
|
||
|
|
'Total size: %s → %s (%+.0f%%)' % (
|
||
|
|
_human(total_orig), _human(total_new), -pct),
|
||
|
|
]
|
||
|
|
return lines
|
||
|
|
|
||
|
|
|
||
|
|
class _ProgressDialog(object):
|
||
|
|
"""Wraps the Qt widgets; all methods run on the GUI thread."""
|
||
|
|
|
||
|
|
def __init__(self, title):
|
||
|
|
from qt.core import (
|
||
|
|
QDialog, QVBoxLayout, QPlainTextEdit, QLabel, QProgressBar,
|
||
|
|
QDialogButtonBox,
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
from calibre.gui2.ui import get_gui
|
||
|
|
parent = get_gui()
|
||
|
|
except Exception:
|
||
|
|
parent = None
|
||
|
|
|
||
|
|
self.dlg = QDialog(parent)
|
||
|
|
self.dlg.setWindowTitle('CrossPoint optimizer')
|
||
|
|
self.dlg.setModal(False)
|
||
|
|
self.dlg.resize(660, 480)
|
||
|
|
layout = QVBoxLayout(self.dlg)
|
||
|
|
|
||
|
|
self.header = QLabel(title)
|
||
|
|
self.header.setWordWrap(True)
|
||
|
|
layout.addWidget(self.header)
|
||
|
|
|
||
|
|
self.bar = QProgressBar(self.dlg)
|
||
|
|
self.bar.setRange(0, 0) # indeterminate until finished
|
||
|
|
layout.addWidget(self.bar)
|
||
|
|
|
||
|
|
self.view = QPlainTextEdit(self.dlg)
|
||
|
|
self.view.setReadOnly(True)
|
||
|
|
self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||
|
|
try:
|
||
|
|
from qt.core import QFontDatabase
|
||
|
|
self.view.setFont(
|
||
|
|
QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
layout.addWidget(self.view)
|
||
|
|
|
||
|
|
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||
|
|
self.buttons.rejected.connect(self.dlg.reject)
|
||
|
|
self.buttons.accepted.connect(self.dlg.accept)
|
||
|
|
self.buttons.button(QDialogButtonBox.StandardButton.Close).setEnabled(False)
|
||
|
|
layout.addWidget(self.buttons)
|
||
|
|
|
||
|
|
def show(self):
|
||
|
|
self.dlg.show()
|
||
|
|
self.dlg.raise_()
|
||
|
|
self.dlg.activateWindow()
|
||
|
|
|
||
|
|
def append(self, line):
|
||
|
|
self.view.appendPlainText(line)
|
||
|
|
sb = self.view.verticalScrollBar()
|
||
|
|
sb.setValue(sb.maximum())
|
||
|
|
|
||
|
|
def finish(self, payload):
|
||
|
|
from qt.core import QDialogButtonBox
|
||
|
|
for line in _summary_lines(payload):
|
||
|
|
self.view.appendPlainText(line)
|
||
|
|
self.bar.setRange(0, 1)
|
||
|
|
self.bar.setValue(1)
|
||
|
|
self.header.setText('Optimization complete.')
|
||
|
|
self.buttons.button(QDialogButtonBox.StandardButton.Close).setEnabled(True)
|
||
|
|
self.dlg.raise_()
|
||
|
|
|
||
|
|
|
||
|
|
class _Bridge(QObject):
|
||
|
|
"""Lives on the GUI thread; drives the dialog when signalled from any thread."""
|
||
|
|
|
||
|
|
begin_signal = pyqtSignal(object)
|
||
|
|
step_signal = pyqtSignal(object)
|
||
|
|
finish_signal = pyqtSignal(object)
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
super().__init__()
|
||
|
|
q = Qt.ConnectionType.QueuedConnection
|
||
|
|
self.begin_signal.connect(self._on_begin, type=q)
|
||
|
|
self.step_signal.connect(self._on_step, type=q)
|
||
|
|
self.finish_signal.connect(self._on_finish, type=q)
|
||
|
|
self._dialog = None
|
||
|
|
|
||
|
|
def _on_begin(self, title):
|
||
|
|
try:
|
||
|
|
self._dialog = _ProgressDialog(title)
|
||
|
|
self._dialog.show()
|
||
|
|
except Exception as exc:
|
||
|
|
self._dialog = None
|
||
|
|
add_log(f'[CrossPoint] failed to open optimizer dialog: {exc}')
|
||
|
|
|
||
|
|
def _on_step(self, line):
|
||
|
|
if self._dialog is not None:
|
||
|
|
try:
|
||
|
|
self._dialog.append(line)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _on_finish(self, payload):
|
||
|
|
if self._dialog is not None:
|
||
|
|
try:
|
||
|
|
self._dialog.finish(payload)
|
||
|
|
except Exception as exc:
|
||
|
|
add_log(f'[CrossPoint] optimizer dialog finish failed: {exc}')
|
||
|
|
|
||
|
|
|
||
|
|
_bridge = None
|
||
|
|
|
||
|
|
|
||
|
|
def ensure_bridge():
|
||
|
|
"""Create the GUI-thread bridge. Must be called on the GUI (main) thread.
|
||
|
|
|
||
|
|
Called at plugin import (startup, main thread) and again from config_widget()
|
||
|
|
as a belt-and-suspenders. No-op off the main thread or without a QApplication.
|
||
|
|
"""
|
||
|
|
global _bridge
|
||
|
|
if _bridge is not None:
|
||
|
|
return _bridge
|
||
|
|
app = QApplication.instance()
|
||
|
|
if app is None:
|
||
|
|
return None
|
||
|
|
if QThread.currentThread() != app.thread():
|
||
|
|
return None # cannot safely create a QObject for the GUI thread here
|
||
|
|
_bridge = _Bridge()
|
||
|
|
return _bridge
|
||
|
|
|
||
|
|
|
||
|
|
# --- API called from the device-manager (worker) thread ---------------------
|
||
|
|
|
||
|
|
def begin(title):
|
||
|
|
b = _bridge or ensure_bridge()
|
||
|
|
if b is not None:
|
||
|
|
b.begin_signal.emit(title)
|
||
|
|
|
||
|
|
|
||
|
|
def step(tag, message):
|
||
|
|
b = _bridge
|
||
|
|
if b is not None:
|
||
|
|
b.step_signal.emit('[%s] %s' % (tag, message))
|
||
|
|
|
||
|
|
|
||
|
|
def finish(payload):
|
||
|
|
b = _bridge
|
||
|
|
if b is not None:
|
||
|
|
b.finish_signal.emit(payload)
|