Source code for riverarchitect.gui.qt.base

"""Shared behaviour for Qt module tabs.

The tkinter base class (:mod:`riverarchitect.gui.base`) owns its own menu bar and resizes
the whole window whenever its tab is selected. Neither is right in a Qt application: menus
belong to the main window, and a tab must not move the window under the user. What is left
here is what a tab genuinely shares - the unit system, the condition list, and a way to run
work off the interface thread.
"""

import logging
import os
import threading

from ... import config
from .qtcompat import (QFileDialog, QLabel, QMessageBox, QSizePolicy, QVBoxLayout, QWidget,
                       Qt, Signal)

__all__ = ["RaTab"]


[docs] class RaTab(QWidget): """Base class for a module tab. Subclasses set :attr:`title` and :attr:`subtitle`, then build their widgets into :attr:`body`. Long-running work goes through :meth:`run_in_background`, which keeps the interface responsive and marshals the result back onto the Qt thread. """ #: Tab label. title = "River Architect" #: One or two lines shown under the tab heading. subtitle = "" #: Emitted from a worker thread when background work finishes. Internal. _finished = Signal(object, object) def __init__(self, parent=None): super().__init__(parent) self.logger = logging.getLogger("riverarchitect") self.unit = "us" self.labels = config.unit_labels(self.unit) self.condition_list = [] self.refresh_conditions() layout = QVBoxLayout(self) layout.setContentsMargins(16, 16, 16, 16) layout.setSpacing(10) heading = QLabel(self.title) font = heading.font() font.setPointSize(font.pointSize() + 4) font.setBold(True) heading.setFont(font) layout.addWidget(heading) if self.subtitle: note = QLabel(self.subtitle) note.setWordWrap(True) note.setStyleSheet("color: palette(mid);") layout.addWidget(note) self.body = QWidget(self) self.body.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding) layout.addWidget(self.body) self._finished.connect(self._on_finished) self._callback = None # ------------------------------------------------------------------ background
[docs] def run_in_background(self, work, on_success, on_error): """Run ``work()`` on a worker thread and deliver the outcome on the Qt thread. Qt widgets may only be touched from the thread that created them, so the worker emits a signal instead of calling back directly. A queued signal connection is the Qt equivalent of tkinter's ``widget.after(0, ...)``. """ self._callback = (on_success, on_error) def runner(): try: self._finished.emit(work(), None) except Exception as exc: # surfaced to the user by the error callback self._finished.emit(None, exc) threading.Thread(target=runner, daemon=True).start()
def _on_finished(self, result, error): if self._callback is None: return on_success, on_error = self._callback self._callback = None if error is not None: on_error(error) else: on_success(result) # ----------------------------------------------------------------------- units
[docs] def set_unit(self, unit): """Switch the unit system and notify the subclass through :meth:`on_unit_change`.""" if unit not in config.UNITS or unit == self.unit: return self.unit = unit self.labels = config.unit_labels(unit) self.on_unit_change()
[docs] def on_unit_change(self): """Hook called after the unit system changed. Does nothing by default."""
# ------------------------------------------------------------------ conditions
[docs] def refresh_conditions(self): """Reload the list of conditions available in the project directory.""" try: self.condition_list = sorted( name for name in os.listdir(config.dir_conditions()) if os.path.isdir(os.path.join(config.dir_conditions(), name)) and not name.startswith(".")) except OSError: self.condition_list = [] return self.condition_list
[docs] def on_project_home_change(self): """Hook called after the project directory changed. Refreshes the condition list.""" self.refresh_conditions()
# ------------------------------------------------------------------- utilities
[docs] def choose_file(self, caption, patterns="Raster files (*.tif *.tiff *.flt *.asc);;" "All files (*)"): path, _ = QFileDialog.getOpenFileName(self, caption, config.project_home(), patterns) return path
[docs] def choose_directory(self, caption, start=None): return QFileDialog.getExistingDirectory(self, caption, start or config.project_home())
[docs] def warn(self, title, text): QMessageBox.warning(self, title, text)
[docs] def fail(self, title, text): QMessageBox.critical(self, title, text)
[docs] @staticmethod def elide(path, limit=54): """Shorten a path for a button label, keeping the file name readable.""" if not path: return "" if len(path) <= limit: return path return "..." + os.sep + os.path.basename(path)