diff --git a/dgp/__init__.py b/dgp/__init__.py index e69de29..6010a08 100644 --- a/dgp/__init__.py +++ b/dgp/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +__version__ = "0.1.0" + +__about__ = f""" +DGP version {__version__} + +DGP (Dynamic Gravity Processor) is an open source project licensed under the Apache v2 license. + +The source for DGP is available at https://github.com/DynamicGravitySystems/DGP + +DGP is written in Python, utilizing the Qt framework with the PyQt5 Python bindings. + +""" diff --git a/dgp/__main__.py b/dgp/__main__.py index 436b6ed..a2c8639 100644 --- a/dgp/__main__.py +++ b/dgp/__main__.py @@ -1,17 +1,17 @@ -# coding: utf-8 - -import os +# -*- coding: utf-8 -*- import sys +import time import traceback -sys.path.append(os.path.dirname(__file__)) - +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPixmap from PyQt5 import QtCore -from PyQt5.QtWidgets import QApplication -from dgp.gui.splash import SplashScreen +from PyQt5.QtWidgets import QApplication, QSplashScreen + +from dgp.gui.main import MainWindow -def excepthook(type_, value, traceback_): +def excepthook(type_, value, traceback_): # pragma: no cover """This allows IDE to properly display unhandled exceptions which are otherwise silently ignored as the application is terminated. Override default excepthook with @@ -24,13 +24,21 @@ def excepthook(type_, value, traceback_): app = None +_align = Qt.AlignBottom | Qt.AlignHCenter -def main(): +def main(): # pragma: no cover global app sys.excepthook = excepthook app = QApplication(sys.argv) - form = SplashScreen() + splash = QSplashScreen(QPixmap(":/icons/dgp_large")) + splash.showMessage("Loading Dynamic Gravity Processor", _align) + splash.show() + time.sleep(.5) + window = MainWindow() + splash.finish(window) + window.sigStatusMessage.connect(lambda msg: splash.showMessage(msg, _align)) + window.load() sys.exit(app.exec_()) diff --git a/dgp/core/__init__.py b/dgp/core/__init__.py index d5f704f..2a08eae 100644 --- a/dgp/core/__init__.py +++ b/dgp/core/__init__.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- - -__all__ = ['OID', 'Reference', 'StateAction'] - from .oid import OID from .types.reference import Reference -from .types.enumerations import * +from .types.enumerations import DataType, StateAction, Icon + +__all__ = ['OID', 'Reference', 'DataType', 'StateAction', 'Icon'] diff --git a/dgp/core/controllers/datafile_controller.py b/dgp/core/controllers/datafile_controller.py index 11d5c5b..816768c 100644 --- a/dgp/core/controllers/datafile_controller.py +++ b/dgp/core/controllers/datafile_controller.py @@ -23,13 +23,16 @@ def __init__(self, datafile: DataFile, dataset=None): self._bindings = [ ('addAction', ('Properties', self._properties_dlg)), - ('addAction', (QIcon(Icon.OPEN_FOLDER.value), 'Show in Explorer', + ('addAction', (Icon.OPEN_FOLDER.icon(), 'Show in Explorer', self._launch_explorer)) ] @property def uid(self) -> OID: - return self._datafile.uid + try: + return self._datafile.uid + except AttributeError: + return None @property def dataset(self) -> IDataSetController: @@ -58,9 +61,9 @@ def set_datafile(self, datafile: DataFile): self.setToolTip("Source path: {!s}".format(datafile.source_path)) self.setData(datafile, role=Qt.UserRole) if self._datafile.group is DataType.GRAVITY: - self.setIcon(QIcon(Icon.GRAVITY.value)) + self.setIcon(Icon.GRAVITY.icon()) elif self._datafile.group is DataType.TRAJECTORY: - self.setIcon(QIcon(Icon.TRAJECTORY.value)) + self.setIcon(Icon.TRAJECTORY.icon()) def _properties_dlg(self): if self._datafile is None: diff --git a/dgp/core/controllers/dataset_controller.py b/dgp/core/controllers/dataset_controller.py index 7377802..1e53fdb 100644 --- a/dgp/core/controllers/dataset_controller.py +++ b/dgp/core/controllers/dataset_controller.py @@ -4,21 +4,23 @@ from pathlib import Path from typing import List, Union, Generator, Set +from PyQt5.QtWidgets import QInputDialog from pandas import DataFrame, Timestamp, concat from PyQt5.QtCore import Qt -from PyQt5.QtGui import QColor, QBrush, QIcon, QStandardItemModel, QStandardItem +from PyQt5.QtGui import QColor, QBrush, QStandardItemModel, QStandardItem -from dgp.core.oid import OID -from dgp.core.types.enumerations import Icon +from dgp.core import OID, Icon from dgp.core.hdf5_manager import HDF5Manager -from dgp.core.controllers import controller_helpers from dgp.core.models.datafile import DataFile from dgp.core.models.dataset import DataSet, DataSegment from dgp.core.types.enumerations import DataType, StateColor from dgp.gui.plotting.helpers import LinearSegmentGroup from dgp.lib.etc import align_frames -from .controller_interfaces import IFlightController, IDataSetController, IBaseController +from . import controller_helpers +from .gravimeter_controller import GravimeterController +from .controller_interfaces import (IFlightController, IDataSetController, + IBaseController, IAirborneController) from .project_containers import ProjectFolder from .datafile_controller import DataFileController @@ -83,20 +85,19 @@ def __init__(self, dataset: DataSet, flight: IFlightController): super().__init__() self._dataset = dataset self._flight: IFlightController = flight - self._project = self._flight.project self._active = False self.log = logging.getLogger(__name__) self.setEditable(False) self.setText(self._dataset.name) - self.setIcon(QIcon(Icon.OPEN_FOLDER.value)) + self.setIcon(Icon.PLOT_LINE.icon()) self.setBackground(QBrush(QColor(StateColor.INACTIVE.value))) self._grav_file = DataFileController(self._dataset.gravity, self) self._traj_file = DataFileController(self._dataset.trajectory, self) self._child_map = {DataType.GRAVITY: self._grav_file, DataType.TRAJECTORY: self._traj_file} - self._segments = ProjectFolder("Segments") + self._segments = ProjectFolder("Segments", Icon.LINE_MODE.icon()) for segment in dataset.segments: seg_ctrl = DataSegmentController(segment, parent=self) self._segments.appendRow(seg_ctrl) @@ -105,6 +106,13 @@ def __init__(self, dataset: DataSet, flight: IFlightController): self.appendRow(self._traj_file) self.appendRow(self._segments) + self._sensor = None + if dataset.sensor is not None: + ctrl = self.project.get_child(dataset.sensor.uid) + if ctrl is not None: + self._sensor = ctrl.clone() + self.appendRow(self._sensor) + self._gravity: DataFrame = DataFrame() self._trajectory: DataFrame = DataFrame() self._dataframe: DataFrame = DataFrame() @@ -114,13 +122,13 @@ def __init__(self, dataset: DataSet, flight: IFlightController): self._menu_bindings = [ # pragma: no cover ('addAction', ('Set Name', self._set_name)), ('addAction', ('Set Active', lambda: self.get_parent().activate_child(self.uid))), - ('addAction', (QIcon(Icon.METER.value), 'Set Sensor', - self._set_sensor_dlg)), + ('addAction', (Icon.METER.icon(), 'Set Sensor', + self._action_set_sensor_dlg)), ('addSeparator', ()), - ('addAction', (QIcon(Icon.GRAVITY.value), 'Import Gravity', - lambda: self._project.load_file_dlg(DataType.GRAVITY, dataset=self))), - ('addAction', (QIcon(Icon.TRAJECTORY.value), 'Import Trajectory', - lambda: self._project.load_file_dlg(DataType.TRAJECTORY, dataset=self))), + ('addAction', (Icon.GRAVITY.icon(), 'Import Gravity', + lambda: self.project.load_file_dlg(DataType.GRAVITY, dataset=self))), + ('addAction', (Icon.TRAJECTORY.icon(), 'Import Trajectory', + lambda: self.project.load_file_dlg(DataType.TRAJECTORY, dataset=self))), ('addAction', ('Align Data', self.align)), ('addSeparator', ()), ('addAction', ('Delete', lambda: self.get_parent().remove_child(self.uid))), @@ -134,6 +142,10 @@ def clone(self): def uid(self) -> OID: return self._dataset.uid + @property + def project(self) -> IAirborneController: + return self._flight.get_parent() + @property def hdfpath(self) -> Path: return self._flight.get_parent().hdfpath @@ -307,6 +319,22 @@ def _set_name(self): if name: self.set_attr('name', name) - def _set_sensor_dlg(self): - # TODO: Dialog to enable selection of sensor assoc with the dataset - pass + def _action_set_sensor_dlg(self): + sensors = {} + for i in range(self.project.meter_model.rowCount()): + sensor = self.project.meter_model.item(i) + sensors[sensor.text()] = sensor + + item, ok = QInputDialog.getItem(self.parent_widget, "Select Gravimeter", + "Sensor", sensors.keys(), editable=False) + if ok: + if self._sensor is not None: + self.removeRow(self._sensor.row()) + + sensor: GravimeterController = sensors[item] + self.set_attr('sensor', sensor) + self._sensor: GravimeterController = sensor.clone() + self.appendRow(self._sensor) + + def _action_delete(self, confirm: bool = True): + self.get_parent().remove_child(self.uid, confirm) diff --git a/dgp/core/controllers/flight_controller.py b/dgp/core/controllers/flight_controller.py index 7d868fc..23c51c4 100644 --- a/dgp/core/controllers/flight_controller.py +++ b/dgp/core/controllers/flight_controller.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- import logging -from _weakrefset import WeakSet +from weakref import WeakSet from typing import Union from PyQt5.QtCore import Qt @@ -12,7 +12,7 @@ from dgp.core.controllers.controller_interfaces import IAirborneController, IFlightController from dgp.core.models.dataset import DataSet from dgp.core.models.flight import Flight -from dgp.core.types.enumerations import DataType, StateColor +from dgp.core.types.enumerations import DataType, StateColor, Icon from dgp.gui.dialogs.add_flight_dialog import AddFlightDialog @@ -54,6 +54,7 @@ def __init__(self, flight: Flight, project: IAirborneController): self._parent = project self._active: bool = False self.setData(flight, Qt.UserRole) + self.setIcon(Icon.AIRBORNE.icon()) self.setEditable(False) self.setBackground(QColor(StateColor.INACTIVE.value)) diff --git a/dgp/core/controllers/gravimeter_controller.py b/dgp/core/controllers/gravimeter_controller.py index 30a32d4..09c1abe 100644 --- a/dgp/core/controllers/gravimeter_controller.py +++ b/dgp/core/controllers/gravimeter_controller.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from PyQt5.QtCore import Qt +from dgp.core import Icon from dgp.core.oid import OID from dgp.core.controllers.controller_interfaces import IAirborneController, IMeterController from dgp.core.controllers.controller_helpers import get_input @@ -13,6 +14,7 @@ def __init__(self, meter: Gravimeter, parent: IAirborneController = None): super().__init__(meter.name) self.setEditable(False) self.setData(meter, role=Qt.UserRole) + self.setIcon(Icon.METER.icon()) self._meter = meter # type: Gravimeter self._parent = parent diff --git a/dgp/core/controllers/project_containers.py b/dgp/core/controllers/project_containers.py index bb1310a..edaf2d0 100644 --- a/dgp/core/controllers/project_containers.py +++ b/dgp/core/controllers/project_containers.py @@ -3,6 +3,8 @@ from PyQt5.QtGui import QStandardItem, QStandardItemModel, QIcon +from dgp.core import Icon + class ProjectFolder(QStandardItem): """Displayable StandardItem used for grouping sub-elements. @@ -17,14 +19,13 @@ class ProjectFolder(QStandardItem): ----- Overriding object methods like __getitem__ __iter__ etc seems to break """ - inherit_context = False - def __init__(self, label: str, icon: str=None, inherit=False, **kwargs): + def __init__(self, label: str, icon: QIcon = None, **kwargs): super().__init__(label) - if icon is not None: - self.setIcon(QIcon(icon)) + if icon is None: + icon = Icon.OPEN_FOLDER.icon() + self.setIcon(icon) self._model = QStandardItemModel() - self.inherit_context = inherit self.setEditable(False) self._attributes = kwargs diff --git a/dgp/core/controllers/project_controllers.py b/dgp/core/controllers/project_controllers.py index ac6d8c2..58dfc56 100644 --- a/dgp/core/controllers/project_controllers.py +++ b/dgp/core/controllers/project_controllers.py @@ -6,7 +6,7 @@ from typing import Union, List, Generator, cast from PyQt5.QtCore import Qt, QRegExp -from PyQt5.QtGui import QColor, QStandardItemModel, QIcon, QRegExpValidator +from PyQt5.QtGui import QColor, QStandardItemModel, QRegExpValidator from pandas import DataFrame from .project_treemodel import ProjectTreeModel @@ -54,27 +54,29 @@ def __init__(self, project: AirborneProject, path: Path = None): self._parent = None self._active = None - self.setIcon(QIcon(Icon.DGS.value)) + self.setIcon(Icon.DGP_NOTEXT.icon()) self.setToolTip(str(self._project.path.resolve())) self.setData(project, Qt.UserRole) self.setBackground(QColor(StateColor.INACTIVE.value)) - self.flights = ProjectFolder("Flights", Icon.AIRBORNE.value) + self.flights = ProjectFolder("Flights") self.appendRow(self.flights) - self.meters = ProjectFolder("Gravimeters", Icon.METER.value) + self.meters = ProjectFolder("Gravimeters") self.appendRow(self.meters) self._child_map = {Flight: self.flights, Gravimeter: self.meters} - for flight in self.project.flights: - controller = FlightController(flight, project=self) - self.flights.appendRow(controller) - + # It is important that GravimeterControllers are defined before Flights + # Flights may create references to a Gravimeter object, but not vice versa for meter in self.project.gravimeters: controller = GravimeterController(meter, parent=self) self.meters.appendRow(controller) + for flight in self.project.flights: + controller = FlightController(flight, project=self) + self.flights.appendRow(controller) + self._bindings = [ ('addAction', ('Set Project Name', self.set_name)), ('addAction', ('Show in Explorer', @@ -291,7 +293,9 @@ def _on_load(datafile: DataFile, params: dict, parent: IDataSetController): else: self.log.error("Unrecognized data group: " + datafile.group) return - progress_event = ProgressEvent(self.uid, f"Loading {datafile.group.value}", stop=0) + progress_event = ProgressEvent(self.uid, f"Loading " + f"{datafile.group.value}", + stop=0) self.get_parent().progressNotificationRequested.emit(progress_event) loader = FileLoader(datafile.source_path, method, parent=self.parent_widget, **params) diff --git a/dgp/core/controllers/project_treemodel.py b/dgp/core/controllers/project_treemodel.py index 06ed20e..736c411 100644 --- a/dgp/core/controllers/project_treemodel.py +++ b/dgp/core/controllers/project_treemodel.py @@ -2,14 +2,13 @@ import logging from typing import Optional, Generator, Union -from PyQt5.QtCore import QObject, QModelIndex, pyqtSignal, QSortFilterProxyModel, Qt +from PyQt5.QtCore import QObject, QModelIndex, pyqtSignal from PyQt5.QtGui import QStandardItemModel -from dgp.core.types.enumerations import DataType -from dgp.core.oid import OID +from dgp.core import OID, DataType from dgp.core.controllers.controller_interfaces import (IFlightController, IAirborneController, - IDataSetController) + IBaseController) from dgp.core.controllers.controller_helpers import confirm_action from dgp.gui.utils import ProgressEvent @@ -44,29 +43,21 @@ class ProjectTreeModel(QStandardItemModel): Signal emitted to request a QProgressDialog from the main window. ProgressEvent is passed defining the parameters for the progress bar - Notes - ----- - ProjectTreeModel loosely conforms to the IParent interface, and uses method - names reflecting the projects that it contains as children. - Part of the reason for this naming scheme is the conflict of the 'child' - property defined in IParent with the child() method of QObject (inherited - by QStandardItemModel). - So, although the ProjectTreeModel tries to conform with the overall parent - interface model, the relationship between Projects and the TreeModel is - special. - """ activeProjectChanged = pyqtSignal(str) projectMutated = pyqtSignal() - tabOpenRequested = pyqtSignal(OID, object, str) + projectClosed = pyqtSignal(OID) + tabOpenRequested = pyqtSignal(object, object) tabCloseRequested = pyqtSignal(OID) progressNotificationRequested = pyqtSignal(ProgressEvent) - def __init__(self, project: IAirborneController, parent: Optional[QObject] = None): + def __init__(self, project: IAirborneController = None, + parent: Optional[QObject] = None): super().__init__(parent) self.log = logging.getLogger(__name__) - self.appendRow(project) - project.set_active(True) + if project is not None: + self.appendRow(project) + project.set_active(True) @property def active_project(self) -> Union[IAirborneController, None]: @@ -110,33 +101,30 @@ def remove_project(self, child: IAirborneController, confirm: bool = True) -> No self.tabCloseRequested.emit(flt.uid) child.save() self.removeRow(child.row()) + self.projectClosed.emit(child.uid) def close_flight(self, flight: IFlightController): self.tabCloseRequested.emit(flight.uid) - def notify_tab_changed(self, flight: IFlightController): - flight.get_parent().activate_child(flight.uid) - def item_selected(self, index: QModelIndex): """Single-click handler for View events""" pass def item_activated(self, index: QModelIndex): """Double-click handler for View events""" - item = self.itemFromIndex(index) - if isinstance(item, IFlightController): - item.get_parent().activate_child(item.uid) - self.tabOpenRequested.emit(item.uid, item, item.get_attr('name')) - elif isinstance(item, IAirborneController): + if not isinstance(item, IBaseController): + return + + if isinstance(item, IAirborneController): for project in self.projects: if project is item: project.set_active(True) else: project.set_active(False) self.activeProjectChanged.emit(item.get_attr('name')) - elif isinstance(item, IDataSetController): - item.get_parent().activate_child(item.uid) + + self.tabOpenRequested.emit(item.uid, item) def project_mutated(self, project: IAirborneController): self.projectMutated.emit() @@ -168,33 +156,3 @@ def add_flight(self): # pragma: no cover def _warn_no_active_project(self): self.log.warning("No active projects.") - - -# Experiment -class ProjectTreeProxyModel(QSortFilterProxyModel): # pragma: no cover - """Experiment to filter tree model to a subset - not working currently, may require - more detailed custom implementation of QAbstractProxyModel - """ - def __init__(self, parent=None): - super().__init__(parent) - self._filter_type = None - self.setRecursiveFilteringEnabled(True) - - def setFilterType(self, obj: type): - self._filter_type = obj - - def sourceModel(self) -> QStandardItemModel: - return super().sourceModel() - - def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex): - index: QModelIndex = self.sourceModel().index(source_row, 0, source_parent) - item = self.sourceModel().itemFromIndex(index) - print(item) - data = self.sourceModel().data(index, self.filterRole()) - disp = self.sourceModel().data(index, Qt.DisplayRole) - - res = isinstance(data, self._filter_type) - print("Result is: %s for row %d" % (str(res), source_row)) - print("Row display value: " + str(disp)) - - return res diff --git a/dgp/core/models/flight.py b/dgp/core/models/flight.py index 580e04e..9062759 100644 --- a/dgp/core/models/flight.py +++ b/dgp/core/models/flight.py @@ -17,7 +17,7 @@ class Flight: The :class:`Flight` contains meta-data common to the overall flight date flown, duration, notes, etc. - The Flight is also the parent container for 1 or more :class:`DataSet`s + The Flight is also the parent container for 1 or more :class:`DataSet` s which group the Trajectory and Gravity data collected during a flight, and can define segments of data (flight lines), based on the flight path. diff --git a/dgp/core/models/project.py b/dgp/core/models/project.py index 1d39cb0..be36589 100644 --- a/dgp/core/models/project.py +++ b/dgp/core/models/project.py @@ -346,7 +346,7 @@ class AirborneProject(GravityProject): This class is a sub-class of :class:`GravityProject` and simply extends the functionality of the base GravityProject, allowing the addition/removal - of :class:`Flight` objects, in addition to :class:`Gravimeter`s + of :class:`Flight` objects, in addition to :class:`Gravimeter` s Parameters ---------- diff --git a/dgp/core/types/enumerations.py b/dgp/core/types/enumerations.py index b3bd59a..6b72eb6 100644 --- a/dgp/core/types/enumerations.py +++ b/dgp/core/types/enumerations.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- - -import enum import logging -from enum import auto +from enum import Enum, auto +from PyQt5.QtCore import QUrl from PyQt5.QtGui import QIcon __all__ = ['StateAction', 'StateColor', 'Icon', 'ProjectTypes', @@ -14,43 +13,52 @@ 'critical': logging.CRITICAL} -class StateAction(enum.Enum): +class StateAction(Enum): CREATE = auto() UPDATE = auto() DELETE = auto() -class StateColor(enum.Enum): +class StateColor(Enum): ACTIVE = '#11dd11' INACTIVE = '#ffffff' -class Icon(enum.Enum): +class Icon(Enum): """Resource Icon paths for Qt resources""" - AUTOSIZE = ":/icons/autosize" - OPEN_FOLDER = ":/icons/folder_open" - AIRBORNE = ":/icons/airborne" - MARINE = ":/icons/marine" - METER = ":/icons/meter_config" - DGS = ":/icons/dgs" - GRAVITY = ":/icons/gravity" - TRAJECTORY = ":/icons/gps" - NEW_FILE = ":/icons/new_file" - SAVE = ":/icons/save" - ARROW_LEFT = ":/icons/chevron-right" - ARROW_DOWN = ":/icons/chevron-down" - DELETE = "" - GRID = "" - HELP = "" - LINE_MODE = "" - PLOT_LINE = "" - SETTINGS = "" - - def icon(self): - return QIcon(self.value) - - -class LogColors(enum.Enum): + AUTOSIZE = "autosize" + OPEN_FOLDER = "folder_open" + AIRBORNE = "airborne" + MARINE = "marine" + METER = "sensor" + DGS = "dgs" + DGP = "dgp_large" + DGP_SMALL = "dgp" + DGP_NOTEXT = "dgp_notext" + GRAVITY = "gravity" + TRAJECTORY = "gps" + NEW_FILE = "new_file" + SAVE = "save" + DELETE = "delete" + ARROW_LEFT = "chevron-left" + ARROW_RIGHT = "chevron-right" + ARROW_UP = "chevron-up" + ARROW_DOWN = "chevron-down" + LINE_MODE = "line_mode" + PLOT_LINE = "plot_line" + SETTINGS = "settings" + SELECT = "select" + INFO = "info" + HELP = "help_outline" + GRID = "grid_on" + NO_GRID = "grid_off" + TREE = "tree" + + def icon(self, prefix="icons"): + return QIcon(f':/{prefix}/{self.value}') + + +class LogColors(Enum): DEBUG = 'blue' INFO = 'yellow' WARNING = 'brown' @@ -58,12 +66,12 @@ class LogColors(enum.Enum): CRITICAL = 'orange' -class ProjectTypes(enum.Enum): +class ProjectTypes(Enum): AIRBORNE = 'airborne' MARINE = 'marine' -class MeterTypes(enum.Enum): +class MeterTypes(Enum): """Gravity Meter Types""" AT1A = 'at1a' AT1M = 'at1m' @@ -71,13 +79,13 @@ class MeterTypes(enum.Enum): TAGS = 'tags' -class DataType(enum.Enum): +class DataType(Enum): """Gravity/Trajectory Data Types""" GRAVITY = 'gravity' TRAJECTORY = 'trajectory' -class GravityTypes(enum.Enum): +class GravityTypes(Enum): # TODO: add set of fields specific to each dtype AT1A = ('gravity', 'long_accel', 'cross_accel', 'beam', 'temp', 'status', 'pressure', 'Etemp', 'gps_week', 'gps_sow') @@ -89,9 +97,16 @@ class GravityTypes(enum.Enum): TAGS = ('tags', ) -class GPSFields(enum.Enum): +class GPSFields(Enum): sow = ('week', 'sow', 'lat', 'long', 'ell_ht') hms = ('mdy', 'hms', 'lat', 'long', 'ell_ht') serial = ('datenum', 'lat', 'long', 'ell_ht') +class Links(Enum): + DEV_DOCS = "https://dgp.readthedocs.io/en/develop/" + MASTER_DOCS = "https://dgp.readthedocs.io/en/latest/" + GITHUB = "https://github.com/DynamicGravitySystems/DGP" + + def url(self): + return QUrl(self.value) diff --git a/dgp/gui/__init__.py b/dgp/gui/__init__.py index 8c44fcf..d47a06e 100644 --- a/dgp/gui/__init__.py +++ b/dgp/gui/__init__.py @@ -1,5 +1,4 @@ -# coding: utf-8 +# -*- coding: utf-8 -*- +from .settings import settings, SettingsKey, RecentProjectManager, UserSettings -# from dgp.gui.splash import SplashScreen -# from dgp.gui.main import MainWindow -# from dgp.gui.dialogs import CreateProject, ImportData, AddFlight +__all__ = ['settings', 'SettingsKey', 'RecentProjectManager', 'UserSettings'] diff --git a/dgp/gui/dialogs/add_flight_dialog.py b/dgp/gui/dialogs/add_flight_dialog.py index c25e394..6e86144 100644 --- a/dgp/gui/dialogs/add_flight_dialog.py +++ b/dgp/gui/dialogs/add_flight_dialog.py @@ -21,9 +21,6 @@ def __init__(self, project: IAirborneController, flight: IFlightController = Non self._project = project self._flight = flight - self.cb_gravimeters.setModel(project.meter_model) - self.qpb_add_sensor.clicked.connect(self._project.add_gravimeter_dlg) - # Configure Form Validation self._name_validator = QRegExpValidator(QRegExp("[A-Za-z]+.{2,20}")) self.qle_flight_name.setValidator(self._name_validator) @@ -53,11 +50,6 @@ def accept(self): sequence = self.qsb_sequence.value() duration = self.qsb_duration.value() - meter = self.cb_gravimeters.currentData(role=Qt.UserRole) # type: Gravimeter - - # TODO: Add meter association to flight - # how to make a reference that can be retrieved after loading from JSON? - if self._flight is not None: # Existing flight - update self._flight.set_attr('name', name) @@ -65,7 +57,6 @@ def accept(self): self._flight.set_attr('notes', notes) self._flight.set_attr('sequence', sequence) self._flight.set_attr('duration', duration) - # self._flight.add_child(meter) else: # Create new flight and add it to project flt = Flight(self.qle_flight_name.text(), date=date, diff --git a/dgp/gui/dialogs/create_project_dialog.py b/dgp/gui/dialogs/create_project_dialog.py index be95217..d9e037e 100644 --- a/dgp/gui/dialogs/create_project_dialog.py +++ b/dgp/gui/dialogs/create_project_dialog.py @@ -15,7 +15,7 @@ class CreateProjectDialog(QDialog, Ui_CreateProjectDialog, FormValidator): - sigProjectCreated = pyqtSignal(AirborneProject, bool) + sigProjectCreated = pyqtSignal(AirborneProject) def __init__(self, parent=None): super().__init__(parent=parent) @@ -69,7 +69,8 @@ def accept(self): path.mkdir(parents=True) self._project = AirborneProject(name=name, path=path, description=self.qpte_notes.toPlainText()) - self.sigProjectCreated.emit(self._project, False) + self._project.to_json(to_file=True, indent=2) + self.sigProjectCreated.emit(self._project) else: # pragma: no cover self.ql_validation_err.setText("Invalid Project Type - Not Implemented") return diff --git a/dgp/gui/dialogs/data_import_dialog.py b/dgp/gui/dialogs/data_import_dialog.py index de7caaa..8680137 100644 --- a/dgp/gui/dialogs/data_import_dialog.py +++ b/dgp/gui/dialogs/data_import_dialog.py @@ -56,9 +56,9 @@ def __init__(self, project: IAirborneController, } } - self._gravity = QListWidgetItem(QIcon(Icon.GRAVITY.value), "Gravity") + self._gravity = QListWidgetItem(Icon.GRAVITY.icon(), "Gravity") self._gravity.setData(Qt.UserRole, DataType.GRAVITY) - self._trajectory = QListWidgetItem(QIcon(Icon.TRAJECTORY.value), "Trajectory") + self._trajectory = QListWidgetItem(Icon.TRAJECTORY.icon(), "Trajectory") self._trajectory.setData(Qt.UserRole, DataType.TRAJECTORY) self.qlw_datatype.addItem(self._gravity) diff --git a/dgp/gui/dialogs/recent_project_dialog.py b/dgp/gui/dialogs/recent_project_dialog.py new file mode 100644 index 0000000..5458953 --- /dev/null +++ b/dgp/gui/dialogs/recent_project_dialog.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +import sys +import logging +from pathlib import Path +from typing import Union + +import PyQt5.QtWidgets as QtWidgets +from PyQt5.QtCore import QModelIndex, pyqtSignal + +from dgp.gui import RecentProjectManager +from dgp.gui.utils import ConsoleHandler, LOG_FORMAT, LOG_COLOR_MAP, load_project_from_path +from dgp.gui.dialogs.create_project_dialog import CreateProjectDialog +from dgp.gui.ui.recent_project_dialog import Ui_RecentProjects + + +class RecentProjectDialog(QtWidgets.QDialog, Ui_RecentProjects): + """Display a QDialog with a recent project's list, and ability to browse for, + or create a new project. + Recent projects are retrieved via the QSettings object and global DGP keys. + + """ + sigProjectLoaded = pyqtSignal(object) + + def __init__(self, *args): + super().__init__(*args) + self.setupUi(self) + + # Configure Logging + self.log = self.setup_logging() + # Experimental: Add a logger that sets the label_error text + error_handler = ConsoleHandler(self.write_error) + error_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) + error_handler.setLevel(logging.DEBUG) + self.log.addHandler(error_handler) + + self.recents = RecentProjectManager() + + self.qpb_new_project.clicked.connect(self.new_project) + self.qpb_browse.clicked.connect(self.browse_project) + self.qpb_clear_recents.clicked.connect(self.recents.clear) + + self.qlv_recents.setModel(self.recents.model) + self.qlv_recents.doubleClicked.connect(self._activated) + + self.show() + + @staticmethod + def setup_logging(level=logging.DEBUG): + root_log = logging.getLogger() + std_err_handler = logging.StreamHandler(sys.stderr) + std_err_handler.setLevel(level) + std_err_handler.setFormatter(LOG_FORMAT) + root_log.addHandler(std_err_handler) + return logging.getLogger(__name__) + + def _activated(self, index: QModelIndex): + self.accept() + + @property + def project_path(self) -> Union[Path, None]: + return self.recents.path(self.qlv_recents.currentIndex()) + + def load_project(self, path: Path): + """Load a project from file and emit the result + + Parameters + ---------- + path + + Returns + ------- + + """ + assert isinstance(path, Path) + project = load_project_from_path(path) + project.path = path # update project's path in case folder was moved + self.sigProjectLoaded.emit(project) + super().accept() + + def accept(self): + if self.project_path is not None: + self.load_project(self.project_path) + super().accept() + else: + self.log.warning("No project selected") + + def new_project(self): + """Allow the user to create a new project""" + dialog = CreateProjectDialog(parent=self) + dialog.sigProjectCreated.connect(self.sigProjectLoaded.emit) + if dialog.exec_(): + super().accept() + + def browse_project(self): + """Allow the user to browse for a project directory and load.""" + path = QtWidgets.QFileDialog.getExistingDirectory(self, "Select Project Dir") + if not path: + return + self.load_project(Path(path)) + + def write_error(self, msg, level=None) -> None: + self.label_error.setText(msg) + self.label_error.setStyleSheet('color: {}'.format(LOG_COLOR_MAP[level])) diff --git a/dgp/gui/main.py b/dgp/gui/main.py index 43a86c7..6755352 100644 --- a/dgp/gui/main.py +++ b/dgp/gui/main.py @@ -1,35 +1,37 @@ # -*- coding: utf-8 -*- - -import pathlib import logging +import warnings +from pathlib import Path import PyQt5.QtWidgets as QtWidgets -from PyQt5.QtCore import Qt, pyqtSlot -from PyQt5.QtGui import QColor -from PyQt5.QtWidgets import QMainWindow, QProgressDialog, QFileDialog, QDialog +from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QByteArray +from PyQt5.QtGui import QColor, QCloseEvent, QDesktopServices +from PyQt5.QtWidgets import QProgressDialog, QFileDialog, QMessageBox, QMenu, QAction +from dgp import __about__ from dgp.core.oid import OID +from dgp.core.types.enumerations import Links, Icon from dgp.core.controllers.controller_interfaces import IBaseController from dgp.core.controllers.project_controllers import AirborneProjectController from dgp.core.controllers.project_treemodel import ProjectTreeModel -from dgp.core.models.project import AirborneProject +from dgp.core.models.project import AirborneProject, GravityProject +from dgp.gui import settings, SettingsKey, RecentProjectManager, UserSettings from dgp.gui.utils import (ConsoleHandler, LOG_FORMAT, LOG_LEVEL_MAP, - LOG_COLOR_MAP, get_project_file, ProgressEvent) + LOG_COLOR_MAP, ProgressEvent, load_project_from_path) from dgp.gui.dialogs.create_project_dialog import CreateProjectDialog - -from dgp.gui.workspace import WorkspaceTab, MainWorkspace +from dgp.gui.dialogs.recent_project_dialog import RecentProjectDialog +from dgp.gui.widgets.workspace_widget import WorkspaceWidget +from dgp.gui.workspaces import tab_factory from dgp.gui.ui.main_window import Ui_MainWindow -class MainWindow(QMainWindow, Ui_MainWindow): +class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): """An instance of the Main Program Window""" + sigStatusMessage = pyqtSignal(str) - def __init__(self, project: AirborneProjectController, *args): + def __init__(self, *args): super().__init__(*args) - self.setupUi(self) - self.workspace: MainWorkspace - self.title = 'Dynamic Gravity Processor [*]' self.setWindowTitle(self.title) @@ -44,29 +46,37 @@ def __init__(self, project: AirborneProjectController, *args): self.log.addHandler(sb_handler) self.log.setLevel(logging.DEBUG) + self.workspace: WorkspaceWidget + self.recents = RecentProjectManager() + self.user_settings = UserSettings() + self._progress_events = {} + # Instantiate the Project Model and display in the ProjectTreeView - self.model = ProjectTreeModel(project, parent=self) + self.model = ProjectTreeModel(parent=self) self.project_tree.setModel(self.model) - self.project_tree.expandAll() - # Initialize Variables - self.import_base_path = pathlib.Path('~').expanduser().joinpath( - 'Desktop') - self._default_status_timeout = 5000 # Status Msg timeout in milli-sec + # Add sub-menu to display recent projects + self.recent_menu = QMenu("Recent Projects") + self.menuFile.addMenu(self.recent_menu) - self._progress_events = {} - self._mutated = False + self.import_base_path = Path('~').expanduser().joinpath('Desktop') + self._default_status_timeout = 5000 # Status Msg timeout in milli-sec - self._init_slots() + # Initialize signal/slot connections: - def _init_slots(self): # pragma: no cover - """Initialize PyQt Signals/Slots for UI Buttons and Menus""" + # Use dock's toggleViewAction to generate QAction, resolves issue where + # dock visibility would be hidden after minimizing the main window + self.action_project_dock: QAction = self.project_dock.toggleViewAction() + self.action_project_dock.setIcon(Icon.TREE.icon()) + self.toolbar.addAction(self.action_project_dock) + self.menuView.addAction(self.action_project_dock) # Model Event Signals # self.model.tabOpenRequested.connect(self._tab_open_requested) self.model.tabCloseRequested.connect(self.workspace.close_tab) self.model.progressNotificationRequested.connect(self._progress_event_handler) self.model.projectMutated.connect(self._project_mutated) + self.model.projectClosed.connect(lambda x: self._update_recent_menu()) # File Menu Actions # self.action_exit.triggered.connect(self.close) @@ -80,31 +90,138 @@ def _init_slots(self): # pragma: no cover self.action_add_flight.triggered.connect(self.model.add_flight) self.action_add_meter.triggered.connect(self.model.add_gravimeter) + # Help Menu Actions # + self.action_docs.triggered.connect(self.show_documentation) + self.action_about.triggered.connect(self.show_about) + # Project Control Buttons # self.prj_add_flight.clicked.connect(self.model.add_flight) self.prj_add_meter.clicked.connect(self.model.add_gravimeter) self.prj_import_gps.clicked.connect(self.model.import_gps) self.prj_import_grav.clicked.connect(self.model.import_gravity) - # Tab Browser Actions # - self.workspace.currentChanged.connect(self._tab_index_changed) - # Console Window Actions # self.combo_console_verbosity.currentIndexChanged[str].connect( self.set_logging_level) - def load(self): - """Called from splash screen to initialize and load main window. - This may be safely deprecated as we currently do not perform any long - running operations on initial load as we once did.""" - self.setWindowState(Qt.WindowMaximized) - self.save_projects() + # Define recent projects menu action + self.recents.sigRecentProjectsChanged.connect(self._update_recent_menu) + self._update_recent_menu() + + def load(self, project: GravityProject = None, restore: bool = True): + """Interactively load the DGP MainWindow, restoring previous widget/dock + state, and any saved geometry state. + + If a project is explicitly specified then the project will be loaded into + the MainWindow, and the window shown. + If no project is specified, the users local settings are checked for the + last project that was active/opened, and it will be loaded into the + window. + Otherwise, a RecentProjectDialog is shown where the user can select from + a list of known recent projects, browse for a project folder, or create + a new project. + + Parameters + ---------- + project : :class:`GravityProject` + Explicitly pass a GravityProject or sub-type to be loaded into the + main window. + restore : bool, optional + If True (default) the MainWindow state and geometry will be restored + from the local settings repository. + + """ + if restore: + self.restoreState(settings().value(SettingsKey.WindowState(), QByteArray())) + self.restoreGeometry(settings().value(SettingsKey.WindowGeom(), QByteArray())) + self.show() + if project is not None: + self.sigStatusMessage.emit(f'Loading project {project.name}') + self.add_project(project) + elif self.recents.last_project_path() is not None and self.user_settings.reopen_last: + self.sigStatusMessage.emit(f'Loading last project') + self.log.info(f"Loading most recent project.") + project = load_project_from_path(self.recents.last_project_path()) + self.add_project(project) + else: + self.sigStatusMessage.emit("Selecting project") + recent_dlg = RecentProjectDialog() + recent_dlg.sigProjectLoaded.connect(self.add_project) + recent_dlg.exec_() + + self.project_tree.expandAll() + + def add_project(self, project: GravityProject): + """Add a project model to the window, first wrapping it in an + appropriate controller class + + Parameters + ---------- + project : :class:`GravityProject` + path : :class:`pathlib.Path` + + + """ + if isinstance(project, AirborneProject): + control = AirborneProjectController(project) + else: + raise TypeError(f'Unsupported project type: {type(project)}') + + self.model.add_project(control) + self.project_tree.setExpanded(control.index(), True) + self.recents.add_recent_project(control.uid, control.get_attr('name'), + control.path) - def closeEvent(self, *args, **kwargs): + def open_project(self, path: Path, prompt: bool = True) -> None: + """Open/load a project from the given path. + + Parameters + ---------- + path : :class:`pathlib.Path` + Directory path containing valid DGP project *.json file + prompt : bool, optional + If True display a message box asking the user if they would like to + open the project in a new window. + Else the project is opened into the current MainWindow + + """ + project = load_project_from_path(path) + if prompt and self.model.rowCount() > 0: + msg_dlg = QMessageBox(QMessageBox.Question, + "Open in New Window", + "Open Project in New Window?", + QMessageBox.Yes | QMessageBox.No, self) + res = msg_dlg.exec_() + else: + res = QMessageBox.No + + if res == QMessageBox.Yes: # Open new MainWindow instance + window = MainWindow() + window.load(project, restore=False) + window.activateWindow() + elif res == QMessageBox.No: # Open in current MainWindow + if project.uid in [p.uid for p in self.model.projects]: + self.log.warning("Project already opened in current workspace") + else: + self.add_project(project) + self.raise_() + + def closeEvent(self, event: QCloseEvent): self.log.info("Saving project and closing.") self.save_projects() - super().closeEvent(*args, **kwargs) + settings().setValue(SettingsKey.WindowState(), self.saveState()) + settings().setValue(SettingsKey.WindowGeom(), self.saveGeometry()) + + # Set last project to active project + if self.model.active_project is not None: + settings().setValue(SettingsKey.LastProjectUid(), + self.model.active_project.uid.base_uuid) + settings().setValue(SettingsKey.LastProjectPath(), + str(self.model.active_project.path.absolute())) + settings().setValue(SettingsKey.LastProjectName(), + self.model.active_project.get_attr("name")) + super().closeEvent(event) def set_logging_level(self, name: str): """PyQt Slot: Changes logging level to passed logging level name.""" @@ -126,49 +243,54 @@ def show_status(self, text, level): if level.lower() == 'error' or level.lower() == 'info': self.statusBar().showMessage(text, self._default_status_timeout) - def _tab_open_requested(self, uid: OID, controller: IBaseController, label: str): + def _update_recent_menu(self): + """Regenerate the recent projects' menu actions + + Retrieves the recent projects references from the + :class:`RecentProjectManager` and adds them to the recent projects list + if they are not already active/open in the workspace. + """ + self.recent_menu.clear() + recents = [ref for ref in self.recents.project_refs + if ref.uid not in [p.uid for p in self.model.projects]] + if len(recents) == 0: + self.recent_menu.setEnabled(False) + else: + self.recent_menu.setEnabled(True) + for ref in recents: + self.recent_menu.addAction(ref.name, lambda: self.open_project(Path(ref.path))) + + def _tab_open_requested(self, uid: OID, controller: IBaseController): """pyqtSlot(OID, IBaseController, str) Parameters ---------- uid controller - label - - Returns - ------- """ - tab = self.workspace.get_tab(uid) - if tab is not None: - self.workspace.setCurrentWidget(tab) + if uid is None: + return + existing = self.workspace.get_tab(uid) + if existing is not None: + self.workspace.setCurrentWidget(existing) else: - self.log.debug("Creating new tab and adding to workspace") - ntab = WorkspaceTab(controller) - self.workspace.addTab(ntab, label) - self.workspace.setCurrentWidget(ntab) + constructor = tab_factory(controller) + if constructor is not None: + tab = constructor(controller) + self.workspace.addTab(tab) + else: + warnings.warn(f"Tab control not implemented for type " + f"{type(controller)}") @pyqtSlot(name='_project_mutated') def _project_mutated(self): """pyqtSlot(None) - Update the MainWindow title bar to reflect unsaved changes in the project + Update the MainWindow title bar to reflect unsaved changes in the project """ - self._mutated = True self.setWindowModified(True) - @pyqtSlot(int, name='_tab_index_changed') - def _tab_index_changed(self, index: int): - """pyqtSlot(int) - Notify the project model when the in-focus Workspace tab changes - - """ - current: WorkspaceTab = self.workspace.currentWidget() - if current is not None: - self.model.notify_tab_changed(current.root) - else: - self.log.debug("No flight tab open") - @pyqtSlot(ProgressEvent, name='_progress_event_handler') def _progress_event_handler(self, event: ProgressEvent): if event.uid in self._progress_events: @@ -200,17 +322,6 @@ def _progress_event_handler(self, event: ProgressEvent): dlg.show() self._progress_events[event.uid] = dlg - def show_progress_status(self, start, stop, label=None) -> QtWidgets.QProgressBar: - """Show a progress bar in the windows Status Bar""" - label = label or 'Loading' - sb = self.statusBar() # type: QtWidgets.QStatusBar - progress = QtWidgets.QProgressBar(self) - progress.setRange(start, stop) - progress.setAttribute(Qt.WA_DeleteOnClose) - progress.setToolTip(label) - sb.addWidget(progress) - return progress - def save_projects(self) -> None: self.model.save_projects() self.setWindowModified(False) @@ -218,7 +329,7 @@ def save_projects(self) -> None: # Project create/open dialog functions ################################### - def new_project_dialog(self) -> QDialog: + def new_project_dialog(self) -> QtWidgets.QDialog: """pyqtSlot() Launch a :class:`CreateProjectDialog` to enable the user to create a new project instance. @@ -233,67 +344,33 @@ def new_project_dialog(self) -> QDialog: Reference to modal CreateProjectDialog """ - def _add_project(prj: AirborneProject, new_window: bool): - self.log.info("Creating new project.") - control = AirborneProjectController(prj) - if new_window: - return MainWindow(control) - else: - self.model.add_project(control) - self.save_projects() - dialog = CreateProjectDialog(parent=self) - dialog.sigProjectCreated.connect(_add_project) + dialog.sigProjectCreated.connect(lambda prj: self.open_project(prj.path, prompt=False)) dialog.show() return dialog - def open_project_dialog(self, *args, path: pathlib.Path=None) -> QFileDialog: + def open_project_dialog(self, *args): # pragma: no cover """pyqtSlot() Opens an existing project within the current Project MainWindow, adding the opened project as a tree item to the Project Tree navigator. - ToDo: Add prompt or flag to launch project in new MainWindow - Parameters ---------- args Consume positional arguments, some buttons connected to this slot will pass a 'checked' boolean flag which is not applicable here. - path : :class:`pathlib.Path` - Path to a directory containing a dgp json project file. - Used to programmatically load a project (without launching the - FileDialog). - - Returns - ------- - QFileDialog - Reference to QFileDialog file-browser dialog when called with no - path argument. """ - - def _project_selected(directory): - prj_dir = pathlib.Path(directory[0]) - prj_file = get_project_file(prj_dir) - if prj_file is None: - self.log.warning("No valid DGP project file found in directory") - return - with prj_file.open('r') as fd: - project = AirborneProject.from_json(fd.read()) - if project.uid in [p.uid for p in self.model.projects]: - self.log.warning("Project is already opened") - else: - control = AirborneProjectController(project, path=prj_dir) - self.model.add_project(control) - self.save_projects() - - if path is not None: - _project_selected([path]) - else: # pragma: no cover - dialog = QFileDialog(self, "Open Project", str(self.import_base_path)) - dialog.setFileMode(QFileDialog.DirectoryOnly) - dialog.setViewMode(QFileDialog.List) - dialog.accepted.connect(lambda: _project_selected(dialog.selectedFiles())) - dialog.setModal(True) - dialog.show() - return dialog + dialog = QFileDialog(self, "Open Project", str(self.import_base_path)) + dialog.setFileMode(QFileDialog.DirectoryOnly) + dialog.setViewMode(QFileDialog.List) + dialog.fileSelected.connect(lambda file: self.open_project(Path(file))) + dialog.exec_() + + def show_documentation(self): # pragma: no cover + """Launch DGP's online documentation (RTD) in the default browser""" + QDesktopServices.openUrl(Links.DEV_DOCS.url()) + + def show_about(self): # pragma: no cover + """Display 'About' information for the DGP project""" + QMessageBox.about(self, "About DGP", __about__) diff --git a/dgp/gui/plotting/__init__.py b/dgp/gui/plotting/__init__.py index e69de29..aa4ee8c 100644 --- a/dgp/gui/plotting/__init__.py +++ b/dgp/gui/plotting/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +__help__ = """ +Click and drag on the plot to pan +Right click and drag the plot to interactively zoom +Right click on the plot to view options specific to each plot area +""" diff --git a/dgp/gui/plotting/backends.py b/dgp/gui/plotting/backends.py index ca3b04b..9ea3514 100644 --- a/dgp/gui/plotting/backends.py +++ b/dgp/gui/plotting/backends.py @@ -5,6 +5,7 @@ from weakref import WeakValueDictionary import pandas as pd +from PyQt5.QtCore import pyqtSignal from PyQt5.QtWidgets import QMenu, QWidgetAction, QWidget, QAction, QToolBar, QMessageBox from pyqtgraph.widgets.GraphicsView import GraphicsView from pyqtgraph.graphicsItems.GraphicsLayout import GraphicsLayout @@ -30,8 +31,8 @@ class Axis(Enum): RIGHT = 'right' -LINE_COLORS = {'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', - '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'} +LINE_COLORS = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', + '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'] # type aliases MaybePlot = Union['DgpPlotItem', None] @@ -347,6 +348,8 @@ class aims to simplify the API for our use cases, and add functionality for :func:`pyqtgraph.mkColor` for color options in the plot (creates a QtGui.QColor) """ + sigPlotCleared = pyqtSignal() + def __init__(self, rows=1, cols=1, background='w', grid=True, sharex=False, multiy=False, timeaxis=False, parent=None): super().__init__(background=background, parent=parent) @@ -433,7 +436,8 @@ def get_plot(self, row: int, col: int = 0, axis: Axis = Axis.LEFT) -> MaybePlot: return plot def add_series(self, series: pd.Series, row: int, col: int = 0, - axis: Axis = Axis.LEFT, autorange: bool = True) -> PlotItem: + axis: Axis = Axis.LEFT, pen=None, + autorange: bool = True) -> PlotDataItem: """Add a pandas :class:`pandas.Series` to the plot at the specified row/column @@ -451,8 +455,8 @@ def add_series(self, series: pd.Series, row: int, col: int = 0, Returns ------- - :class:`pyqtgraph.PlotItem` - The generated PlotItem or derivative created from the data + :class:`pyqtgraph.PlotDataItem` + The generated PlotDataItem or derivative created from the data Raises ------ @@ -469,7 +473,7 @@ def add_series(self, series: pd.Series, row: int, col: int = 0, plot = self.get_plot(row, col, axis) xvals = pd.to_numeric(series.index, errors='coerce') yvals = pd.to_numeric(series.values, errors='coerce') - item = plot.plot(x=xvals, y=yvals, name=series.name, pen=self.pen) + item = plot.plot(x=xvals, y=yvals, name=series.name, pen=pen or self.pen) self._items[index] = item if autorange: plot.autoRange() @@ -536,8 +540,9 @@ def clear(self) -> None: for curve in plot_r.curves[:]: plot_r.legend.removeItem(curve.name()) plot_r.removeItem(curve) + self.sigPlotCleared.emit() - def remove_plotitem(self, item: PlotDataItem) -> None: + def remove_plotitem(self, item: PlotDataItem, autorange=True) -> None: """Alternative method of removing a line by its :class:`pyqtgraph.PlotDataItem` reference, as opposed to using remove_series to remove a named series from a specific plot at row/col @@ -550,11 +555,13 @@ def remove_plotitem(self, item: PlotDataItem) -> None: resides """ - for plot, index in self.gl.items.items(): - if isinstance(plot, PlotItem): # pragma: no branch - if item in plot.dataItems: - plot.legend.removeItem(item.name()) - plot.removeItem(item) + for plot in self.plots: + plot.legend.removeItem(item.name()) + plot.removeItem(item) + if plot.right is not None: + plot.right.removeItem(item) + if autorange: + self.autorange() def find_series(self, name: str) -> List[SeriesIndex]: """Find and return a list of all plot indexes where a series with @@ -697,13 +704,12 @@ def get_toolbar(self, parent=None) -> QToolBar: @staticmethod def help_dialog(parent=None): - QMessageBox.information(parent, "Plot Controls Help", - "Click and drag on the plot to pan\n" - "Right click and drag the plot to interactively zoom\n" - "Right click on the plot to view options specific to each plot area") + from . import __help__ + QMessageBox.information(parent, "Plot Controls Help", __help__) @staticmethod - def make_index(name: str, row: int, col: int = 0, axis: Axis = Axis.LEFT) -> SeriesIndex: + def make_index(name: str, row: int, col: int = 0, + axis: Axis = Axis.LEFT) -> SeriesIndex: """Generate an index referring to a specific plot curve Plot curves (items) can be uniquely identified within the GridPlotWidget diff --git a/dgp/gui/plotting/helpers.py b/dgp/gui/plotting/helpers.py index 6f3c33d..6d00223 100644 --- a/dgp/gui/plotting/helpers.py +++ b/dgp/gui/plotting/helpers.py @@ -327,6 +327,11 @@ def set_movable(self, movable: bool): for segment in self._segments: segment.setMovable(movable) + def set_visibility(self, visible: bool): + for segment in self._segments: + segment.setVisible(visible) + segment._label.setVisible(visible) + def delete(self): """Delete all child segments and emit a DELETE update""" for segment in self._segments: diff --git a/dgp/gui/plotting/plotters.py b/dgp/gui/plotting/plotters.py index 407c21f..e59211d 100644 --- a/dgp/gui/plotting/plotters.py +++ b/dgp/gui/plotting/plotters.py @@ -192,14 +192,25 @@ def onclick(self, ev): # pragma: no cover def get_toolbar(self, parent=None): toolbar = super().get_toolbar(parent) - action_mode = QAction(Icon.LINE_MODE.icon(), "Toggle Selection Mode", self) + action_mode = QAction(Icon.SELECT.icon(), "Toggle Selection Mode", self) action_mode.setCheckable(True) action_mode.setChecked(self.selection_mode) action_mode.toggled.connect(self.set_select_mode) toolbar.addAction(action_mode) + action_seg_visibility = QAction(Icon.LINE_MODE.icon(), + "Toggle Segment Visibility", self) + action_seg_visibility.setCheckable(True) + action_seg_visibility.setChecked(True) + action_seg_visibility.toggled.connect(self.set_segment_visibility) + toolbar.addAction(action_seg_visibility) + return toolbar + def set_segment_visibility(self, state: bool): + for segment in self._segments.values(): + segment.set_visibility(state) + def _check_proximity(self, x, span, proximity=0.03) -> bool: """Check the proximity of a mouse click at location 'x' in relation to any already existing LinearRegions. diff --git a/dgp/gui/settings.py b/dgp/gui/settings.py new file mode 100644 index 0000000..fe6b000 --- /dev/null +++ b/dgp/gui/settings.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +import time +from contextlib import contextmanager +from enum import Enum +from pathlib import Path +from typing import Union, Generator + +from PyQt5.QtCore import QSettings, QModelIndex, QObject, pyqtSignal +from PyQt5.QtGui import QStandardItemModel, QStandardItem + +from dgp.core import OID + +PathRole = 0x101 +RefRole = 0x102 +UidRole = 0x103 +ModRole = 0x104 + +MaybePath = Union[Path, None] + +_ORG = "DynamicGravitySystems" +_APP = "DynamicGravityProcessor" +_settings = QSettings(QSettings.NativeFormat, QSettings.UserScope, _ORG, _APP) +_recent_model = QStandardItemModel() + + +def set_settings(handle: QSettings): + """Set the global QSettings object to a custom handler""" + global _settings + _settings = handle + + +def settings() -> QSettings: + """Expose the global QSettings object""" + return _settings + + +class SettingsKey(Enum): + WindowState = "Window/state" + WindowGeom = "Window/geom" + LastProjectPath = "Project/latest/path" + LastProjectName = "Project/latest/name" + LastProjectUid = "Project/latest/uid" + RecentProjects = "Project/recent" + + # User Option Properties + LoadLastProject = "User/LoadLast" + RestoreWorkspace = "User/RestoreWorkspace" + OpenInNewWindow = "User/OpenInNewWindow" + + def __call__(self): + """Allow retrieval of the enum value using call syntax `()` """ + return self.value + + +@contextmanager +def settings_group(key: str): + _settings.beginGroup(key) + yield settings + _settings.endGroup() + + +class RecentProject: + """Simple project reference, contains the metadata required to load or refer + to a DGP project on the local computer. + Used by the RecentProjectManager to maintain a structured reference to any + specific project. + + RecentProject provides a __hash__ method allowing references to be compared + by their UID hash + """ + def __init__(self, uid: str, name: str, path: str, modified=None, **kwargs): + self.uid: str = uid + self.name: str = name + self.path: str = str(path) + self.modified = modified or time.time() + + def __hash__(self): + return hash(self.uid) + + +class RecentProjectManager(QObject): + """QSettings wrapper used to manage the retrieval/setting of recent projects + that have been loaded for the user. + + """ + sigRecentProjectsChanged = pyqtSignal() + + def __init__(self, qsettings: QSettings = None, parent=None): + super().__init__(parent=parent) + self._settings = qsettings or _settings + self._key = SettingsKey.RecentProjects() + self._model = _recent_model + self._load_recent_projects() + + @property + def model(self): + return self._model + + @property + def project_refs(self) -> Generator[RecentProject, None, None]: + for i in range(self.model.rowCount()): + yield self.model.item(i).data(RefRole) + + def last_project_path(self) -> MaybePath: + raw_path = self._settings.value(SettingsKey.LastProjectPath(), None) + if raw_path is not None: + return Path(raw_path) + else: + return None + + def last_project_name(self) -> Union[str, None]: + return self._settings.value(SettingsKey.LastProjectName(), None) + + def add_recent_project(self, uid: OID, name: str, path: Path) -> None: + """Add a project to the list of recent projects, managed via the + QSettings object + + If the project UID already exists in the recent projects list, update + the entry, otherwise create a new entry, commit it, and add an item + to the model representation. + + Parameters + ---------- + uid : OID + name : str + path : :class:`pathlib.Path` + + """ + self.refresh() + str_path = str(path.absolute()) + ref = RecentProject(uid.base_uuid, name, str_path) + + for i in range(self._model.rowCount()): + child: QStandardItem = self._model.item(i) + if child.data(UidRole) == uid: + child.setText(name) + child.setToolTip(str_path) + child.setData(path, PathRole) + child.setData(ref, RefRole) + break + else: # no break + item = self.item_from_ref(ref) + self._model.insertRow(0, item) + + self._commit_recent_projects() + self.sigRecentProjectsChanged.emit() + + def clear(self) -> None: + """Clear recent projects from the model AND persistent settings state""" + self._model.clear() + self._settings.remove(self._key) + self.sigRecentProjectsChanged.emit() + + def refresh(self) -> None: + """Force a refresh of the recent projects list by reloading state + + Alias for _load_recent_projects + + """ + self._load_recent_projects() + + def path(self, index: QModelIndex) -> MaybePath: + """Retrieve path data from a model item, given the items QModelIndex + + Returns + ------- + Path or None + pathlib.Path object if the item and data exists, else None + + """ + item: QStandardItem = self._model.itemFromIndex(index) + if item == 0: + return None + return item.data(PathRole) + + def _commit_recent_projects(self) -> None: + """Commit the recent projects model to file (via QSettings interface), + replacing any current items at the recent projects key. + + """ + self._settings.remove(self._key) + self._settings.beginWriteArray(self._key) + for i in range(self._model.rowCount()): + self._settings.setArrayIndex(i) + ref = self._model.item(i).data(RefRole) + for key in ref.__dict__: + self._settings.setValue(key, getattr(ref, key, None)) + + self._settings.endArray() + + def _load_recent_projects(self) -> None: + self._model.clear() + + size = self._settings.beginReadArray(self._key) + for i in range(size): + self._settings.setArrayIndex(i) + keys = self._settings.childKeys() + params = {key: self._settings.value(key) for key in keys} + + ref = RecentProject(**params) + item = self.item_from_ref(ref) + self._model.appendRow(item) + self._settings.endArray() + self.sigRecentProjectsChanged.emit() + + @staticmethod + def item_from_ref(ref: RecentProject) -> QStandardItem: + """Create a standardized QStandardItem for the model given a RecentProject + + """ + item = QStandardItem(ref.name) + item.setToolTip(str(ref.path)) + item.setData(Path(ref.path), PathRole) + item.setData(ref.uid, UidRole) + item.setData(ref, RefRole) + item.setEditable(False) + return item + + +class UserSettings: + @property + def reopen_last(self) -> bool: + return bool(_settings.value(SettingsKey.LoadLastProject(), False)) + + @property + def new_window(self) -> bool: + return bool(_settings.value(SettingsKey.OpenInNewWindow(), False)) diff --git a/dgp/gui/splash.py b/dgp/gui/splash.py deleted file mode 100644 index 5f557fe..0000000 --- a/dgp/gui/splash.py +++ /dev/null @@ -1,202 +0,0 @@ -# -*- coding: utf-8 -*- -import sys -import json -import logging -from pathlib import Path -from typing import Dict, Union - -import PyQt5.QtWidgets as QtWidgets -import PyQt5.QtCore as QtCore - -from dgp.core.controllers.project_controllers import AirborneProjectController -from dgp.core.models.project import AirborneProject, GravityProject -from dgp.gui.main import MainWindow -from dgp.gui.utils import ConsoleHandler, LOG_FORMAT, LOG_COLOR_MAP, get_project_file -from dgp.gui.dialogs.create_project_dialog import CreateProjectDialog -from dgp.gui.ui.splash_screen import Ui_Launcher - - -class SplashScreen(QtWidgets.QDialog, Ui_Launcher): - def __init__(self, *args): - super().__init__(*args) - self.log = self.setup_logging() - # Experimental: Add a logger that sets the label_error text - error_handler = ConsoleHandler(self.write_error) - error_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) - error_handler.setLevel(logging.DEBUG) - self.log.addHandler(error_handler) - - self.setupUi(self) - - # TODO: Change this to support other OS's - self.settings_dir = Path.home().joinpath( - 'AppData\Local\DynamicGravitySystems\DGP') - self.recent_file = self.settings_dir.joinpath('recent.json') - if not self.settings_dir.exists(): - self.log.info("Settings Directory doesn't exist, creating.") - self.settings_dir.mkdir(parents=True) - - self.btn_newproject.clicked.connect(self.new_project) - self.btn_browse.clicked.connect(self.browse_project) - self.list_projects.currentItemChanged.connect( - lambda item: self.set_selection(item, accept=False)) - self.list_projects.itemDoubleClicked.connect( - lambda item: self.set_selection(item, accept=True)) - - self.project_path = None # type: Path - - self.set_recent_list() - self.show() - - @staticmethod - def setup_logging(level=logging.DEBUG): - root_log = logging.getLogger() - std_err_handler = logging.StreamHandler(sys.stderr) - std_err_handler.setLevel(level) - std_err_handler.setFormatter(LOG_FORMAT) - root_log.addHandler(std_err_handler) - return logging.getLogger(__name__) - - def accept(self, project: Union[GravityProject, None] = None): - """ - Runs some basic verification before calling super(QDialog).accept(). - """ - - # Case where project object is passed to accept() - if isinstance(project, GravityProject): - self.log.debug("Opening new project: {}".format(project.name)) - elif not self.project_path: - self.log.error("No valid project selected.") - else: - try: - # project = prj.AirborneProject.load(self.project_path) - with open(self.project_path, 'r') as fd: - project = AirborneProject.from_json(fd.read()) - except FileNotFoundError: - self.log.error("Project could not be loaded from path: {}" - .format(self.project_path)) - return - - self.update_recent_files(self.recent_file, - {project.name: project.path}) - - controller = AirborneProjectController(project) - main_window = MainWindow(controller) - main_window.load() - super().accept() - return main_window - - def set_recent_list(self) -> None: - recent_files = self.get_recent_files(self.recent_file) - if not recent_files: - no_recents = QtWidgets.QListWidgetItem("No Recent Projects", - self.list_projects) - no_recents.setFlags(QtCore.Qt.NoItemFlags) - return None - - for name, path in recent_files.items(): - item = QtWidgets.QListWidgetItem('{name} :: {path}'.format( - name=name, path=str(path)), self.list_projects) - item.setData(QtCore.Qt.UserRole, path) - item.setToolTip(str(path.resolve())) - self.list_projects.setCurrentRow(0) - return None - - def set_selection(self, item: QtWidgets.QListWidgetItem, accept=False): - """Called when a recent item is selected""" - self.project_path = get_project_file(item.data(QtCore.Qt.UserRole)) - if not self.project_path: - item.setText("{} - Project Moved or Deleted" - .format(item.data(QtCore.Qt.UserRole))) - return - - self.log.debug("Project path set to {}".format(self.project_path)) - if accept: - self.accept() - - def new_project(self): - """Allow the user to create a new project""" - dialog = CreateProjectDialog() - if dialog.exec_(): - project = dialog.project # type: AirborneProject - if not project.path.exists(): - print("Making directory") - project.path.mkdir(parents=True) - project.to_json(to_file=True) - - self.accept(project) - - def browse_project(self): - """Allow the user to browse for a project directory and load.""" - path = QtWidgets.QFileDialog.getExistingDirectory(self, "Select Project Dir") - if not path: - return - - prj_file = get_project_file(Path(path)) - if not prj_file: - self.log.error("No project files found") - return - - self.project_path = prj_file - self.accept() - - def write_error(self, msg, level=None) -> None: - self.label_error.setText(msg) - self.label_error.setStyleSheet('color: {}'.format(LOG_COLOR_MAP[level])) - - @staticmethod - def update_recent_files(path: Path, update: Dict[str, Path]) -> None: - recents = SplashScreen.get_recent_files(path) - recents.update(update) - SplashScreen.set_recent_files(recents, path) - - @staticmethod - def get_recent_files(path: Path) -> Dict[str, Path]: - """ - Ingests a JSON file specified by path, containing project_name: - project_directory mappings and returns dict of valid projects ( - conducting path checking and conversion to pathlib.Path) - Parameters - ---------- - path : Path - Path object referencing JSON object containing mappings of recent - projects -> project directories - - Returns - ------- - Dict - Dictionary of (str) project_name: (pathlib.Path) project_directory mappings - If the specified path cannot be found, an empty dictionary is returned - """ - try: - with path.open('r') as fd: - raw_dict = json.load(fd) - _checked = {} - for name, strpath in raw_dict.items(): - _path = Path(strpath) - if get_project_file(_path) is not None: - _checked[name] = _path - except FileNotFoundError: - return {} - else: - return _checked - - @staticmethod - def set_recent_files(recent_files: Dict[str, Path], path: Path) -> None: - """ - Take a dictionary of recent projects (project_name: project_dir) and - write it out to a JSON formatted file - specified by path - Parameters - ---------- - recent_files : Dict[str, Path] - - path : Path - - Returns - ------- - None - """ - serializable = {name: str(path) for name, path in recent_files.items()} - with path.open('w+') as fd: - json.dump(serializable, fd) diff --git a/dgp/gui/ui/add_flight_dialog.ui b/dgp/gui/ui/add_flight_dialog.ui index 1305d6b..1894247 100644 --- a/dgp/gui/ui/add_flight_dialog.ui +++ b/dgp/gui/ui/add_flight_dialog.ui @@ -6,8 +6,8 @@ 0 0 - 550 - 466 + 405 + 583 @@ -40,7 +40,11 @@ - + + + Required: Specify a name/reference for this flight + + @@ -77,7 +81,11 @@ - + + + [Optional] Set the flight sequence within the project + + @@ -87,64 +95,28 @@ - - - - - - Sensor - - - cb_gravimeters + + + [Optional] Set the duration of the flight (hours) - - - - - - - 0 - 0 - - - - - - - - Add Sensor... - - - - - - + Flight Notes - - + + + + [Optional] Add notes regarding this flight + + - - - - Qt::Vertical - - - - 20 - 40 - - - - @@ -153,7 +125,11 @@ - + + + false + + diff --git a/dgp/gui/ui/channel_select_dialog.ui b/dgp/gui/ui/channel_select_dialog.ui deleted file mode 100644 index 1076b78..0000000 --- a/dgp/gui/ui/channel_select_dialog.ui +++ /dev/null @@ -1,83 +0,0 @@ - - - ChannelSelection - - - - 0 - 0 - 304 - 300 - - - - Select Data Channels - - - - - - true - - - QAbstractItemView::InternalMove - - - Qt::MoveAction - - - true - - - false - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Close|QDialogButtonBox::Reset - - - - - - - - - dialog_buttons - accepted() - ChannelSelection - accept() - - - 248 - 254 - - - 157 - 274 - - - - - dialog_buttons - rejected() - ChannelSelection - reject() - - - 316 - 260 - - - 286 - 274 - - - - - diff --git a/dgp/gui/ui/main_window.ui b/dgp/gui/ui/main_window.ui index a47b3b1..2d951f8 100644 --- a/dgp/gui/ui/main_window.ui +++ b/dgp/gui/ui/main_window.ui @@ -41,7 +41,7 @@ 0 - + @@ -68,13 +68,13 @@ Help - + + Panels - @@ -213,7 +213,7 @@ - :/icons/meter_config:/icons/meter_config + :/icons/sensor:/icons/sensor @@ -251,7 +251,7 @@ - + @@ -262,7 +262,7 @@ - 242 + 248 246 @@ -422,8 +422,14 @@ - + + + + 0 + 0 + + Debug @@ -464,13 +470,26 @@ - + <html><head/><body><p align="right">Logging Level:</p></body></html> + + + + Qt::Horizontal + + + + 40 + 20 + + + + @@ -479,10 +498,17 @@ - + + + + :/icons/open_in_new:/icons/open_in_new + Documentation + + View Online Documentation + F1 @@ -495,20 +521,6 @@ Ctrl+Q - - - true - - - true - - - Project - - - Alt+1 - - true @@ -574,7 +586,7 @@ - :/icons/meter_config:/icons/meter_config + :/icons/sensor:/icons/sensor Add Meter @@ -586,7 +598,7 @@ - :/icons/dgs:/icons/dgs + :/icons/info:/icons/info Project Info... @@ -627,19 +639,24 @@ Import Gravity - + true - :/icons/dgs:/icons/dgs + :/icons/console:/icons/console - Project Dock + Debug Console - Toggle the Project Sidebar + Toggle Debug Console + + + + + About @@ -650,9 +667,9 @@
dgp.gui.views.project_tree_view
- MainWorkspace + WorkspaceWidget QTabWidget -
dgp.gui.workspace
+
dgp.gui.widgets.workspace_widget
1
@@ -663,38 +680,6 @@ - - action_project_dock - toggled(bool) - project_dock - setVisible(bool) - - - -1 - -1 - - - 149 - 419 - - - - - project_dock - visibilityChanged(bool) - action_project_dock - setChecked(bool) - - - 149 - 419 - - - -1 - -1 - - - action_info_dock toggled(bool) @@ -744,35 +729,35 @@ - action_project_dock_2 - toggled(bool) - project_dock - setVisible(bool) + info_dock + visibilityChanged(bool) + action_debug_Console + setChecked(bool) - -1 - -1 + 744 + 991 - 135 - 459 + -1 + -1 - project_dock - visibilityChanged(bool) - action_project_dock_2 - setChecked(bool) + action_debug_Console + toggled(bool) + info_dock + setVisible(bool) - 135 - 459 - - -1 -1 + + 744 + 991 + diff --git a/dgp/gui/ui/splash_screen.ui b/dgp/gui/ui/recent_project_dialog.ui similarity index 86% rename from dgp/gui/ui/splash_screen.ui rename to dgp/gui/ui/recent_project_dialog.ui index 2e395d1..c499d4d 100644 --- a/dgp/gui/ui/splash_screen.ui +++ b/dgp/gui/ui/recent_project_dialog.ui @@ -1,13 +1,13 @@ - Launcher - + RecentProjects + 0 0 - 604 - 620 + 475 + 752 @@ -24,13 +24,6 @@ :/images/geoid:/images/geoid - - - - <html><head/><body><p align="center"><span style=" font-size:16pt; font-weight:600; color:#55557f;">Dynamic Gravity Processor</span></p></body></html> - - - @@ -40,10 +33,10 @@ - :/images/geoid + :/icons/dgp - true + false Qt::AlignCenter @@ -51,7 +44,7 @@ - + <html><head/><body><p align="center">Version 0.1</p><p align="center">Licensed under the Apache-2.0 License</p></body></html> @@ -79,7 +72,7 @@ 0
- + 2 @@ -92,7 +85,7 @@ - + 100 @@ -108,7 +101,7 @@ - + 0 @@ -139,14 +132,14 @@ 0 - + &New Project - + Browse for a project @@ -172,7 +165,7 @@ - + 40 @@ -236,7 +229,7 @@ dialog_buttons rejected() - Launcher + RecentProjects reject() @@ -252,7 +245,7 @@ dialog_buttons accepted() - Launcher + RecentProjects accept() diff --git a/dgp/gui/ui/resources/AutosizeStretch_16x.png b/dgp/gui/ui/resources/AutosizeStretch_16x.png deleted file mode 100644 index 3bb153a..0000000 Binary files a/dgp/gui/ui/resources/AutosizeStretch_16x.png and /dev/null differ diff --git a/dgp/gui/ui/resources/AutosizeStretch_16x.svg b/dgp/gui/ui/resources/AutosizeStretch_16x.svg deleted file mode 100644 index 0283499..0000000 --- a/dgp/gui/ui/resources/AutosizeStretch_16x.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/dgp/gui/ui/resources/GeoLocation_16x.svg b/dgp/gui/ui/resources/GeoLocation_16x.svg deleted file mode 100644 index f4dfcb3..0000000 --- a/dgp/gui/ui/resources/GeoLocation_16x.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/dgp/gui/ui/resources/apple_grav.svg b/dgp/gui/ui/resources/apple_grav.svg deleted file mode 100644 index 33c2499..0000000 --- a/dgp/gui/ui/resources/apple_grav.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/dgp/gui/ui/resources/autosize.png b/dgp/gui/ui/resources/autosize.png new file mode 100644 index 0000000..8bbac86 Binary files /dev/null and b/dgp/gui/ui/resources/autosize.png differ diff --git a/dgp/gui/ui/resources/boat.png b/dgp/gui/ui/resources/boat.png new file mode 100644 index 0000000..3620adc Binary files /dev/null and b/dgp/gui/ui/resources/boat.png differ diff --git a/dgp/gui/ui/resources/boat_icon.png b/dgp/gui/ui/resources/boat_icon.png deleted file mode 100644 index a82b610..0000000 Binary files a/dgp/gui/ui/resources/boat_icon.png and /dev/null differ diff --git a/dgp/gui/ui/resources/chevron_down.png b/dgp/gui/ui/resources/chevron_down.png new file mode 100644 index 0000000..4c76133 Binary files /dev/null and b/dgp/gui/ui/resources/chevron_down.png differ diff --git a/dgp/gui/ui/resources/chevron_left.png b/dgp/gui/ui/resources/chevron_left.png new file mode 100644 index 0000000..cbf41b4 Binary files /dev/null and b/dgp/gui/ui/resources/chevron_left.png differ diff --git a/dgp/gui/ui/resources/chevron_right.png b/dgp/gui/ui/resources/chevron_right.png new file mode 100644 index 0000000..893a7ca Binary files /dev/null and b/dgp/gui/ui/resources/chevron_right.png differ diff --git a/dgp/gui/ui/resources/chevron_up.png b/dgp/gui/ui/resources/chevron_up.png new file mode 100644 index 0000000..540d3e1 Binary files /dev/null and b/dgp/gui/ui/resources/chevron_up.png differ diff --git a/dgp/gui/ui/resources/console.png b/dgp/gui/ui/resources/console.png new file mode 100644 index 0000000..455ef8d Binary files /dev/null and b/dgp/gui/ui/resources/console.png differ diff --git a/dgp/gui/ui/resources/delete.png b/dgp/gui/ui/resources/delete.png new file mode 100644 index 0000000..0369a90 Binary files /dev/null and b/dgp/gui/ui/resources/delete.png differ diff --git a/dgp/gui/ui/resources/dgp-splash.png b/dgp/gui/ui/resources/dgp-splash.png new file mode 100644 index 0000000..4d028d9 Binary files /dev/null and b/dgp/gui/ui/resources/dgp-splash.png differ diff --git a/dgp/gui/ui/resources/dgp_icon.png b/dgp/gui/ui/resources/dgp_icon.png new file mode 100644 index 0000000..313bdb6 Binary files /dev/null and b/dgp/gui/ui/resources/dgp_icon.png differ diff --git a/dgp/gui/ui/resources/dgp_icon_large.png b/dgp/gui/ui/resources/dgp_icon_large.png new file mode 100644 index 0000000..8d425d3 Binary files /dev/null and b/dgp/gui/ui/resources/dgp_icon_large.png differ diff --git a/dgp/gui/ui/resources/dgp_simple.png b/dgp/gui/ui/resources/dgp_simple.png new file mode 100644 index 0000000..c13917b Binary files /dev/null and b/dgp/gui/ui/resources/dgp_simple.png differ diff --git a/dgp/gui/ui/resources/flight.png b/dgp/gui/ui/resources/flight.png new file mode 100644 index 0000000..e62e937 Binary files /dev/null and b/dgp/gui/ui/resources/flight.png differ diff --git a/dgp/gui/ui/resources/folder_closed.png b/dgp/gui/ui/resources/folder_closed.png new file mode 100644 index 0000000..3cb81f8 Binary files /dev/null and b/dgp/gui/ui/resources/folder_closed.png differ diff --git a/dgp/gui/ui/resources/folder_open.png b/dgp/gui/ui/resources/folder_open.png index aa3569d..96558f3 100644 Binary files a/dgp/gui/ui/resources/folder_open.png and b/dgp/gui/ui/resources/folder_open.png differ diff --git a/dgp/gui/ui/resources/gps.png b/dgp/gui/ui/resources/gps.png new file mode 100644 index 0000000..1f98d96 Binary files /dev/null and b/dgp/gui/ui/resources/gps.png differ diff --git a/dgp/gui/ui/resources/gps_icon.png b/dgp/gui/ui/resources/gps_icon.png deleted file mode 100644 index e52581f..0000000 Binary files a/dgp/gui/ui/resources/gps_icon.png and /dev/null differ diff --git a/dgp/gui/ui/resources/grid_off.png b/dgp/gui/ui/resources/grid_off.png new file mode 100644 index 0000000..2364166 Binary files /dev/null and b/dgp/gui/ui/resources/grid_off.png differ diff --git a/dgp/gui/ui/resources/grid_on.png b/dgp/gui/ui/resources/grid_on.png new file mode 100644 index 0000000..61ade8c Binary files /dev/null and b/dgp/gui/ui/resources/grid_on.png differ diff --git a/dgp/gui/ui/resources/help_outline.png b/dgp/gui/ui/resources/help_outline.png new file mode 100644 index 0000000..b160d34 Binary files /dev/null and b/dgp/gui/ui/resources/help_outline.png differ diff --git a/dgp/gui/ui/resources/info.png b/dgp/gui/ui/resources/info.png new file mode 100644 index 0000000..eec7131 Binary files /dev/null and b/dgp/gui/ui/resources/info.png differ diff --git a/dgp/gui/ui/resources/line_mode.png b/dgp/gui/ui/resources/line_mode.png new file mode 100644 index 0000000..c87df74 Binary files /dev/null and b/dgp/gui/ui/resources/line_mode.png differ diff --git a/dgp/gui/ui/resources/location.png b/dgp/gui/ui/resources/location.png new file mode 100644 index 0000000..eb2c8fa Binary files /dev/null and b/dgp/gui/ui/resources/location.png differ diff --git a/dgp/gui/ui/resources/meter_config.png b/dgp/gui/ui/resources/meter_config.png deleted file mode 100644 index 1fc155b..0000000 Binary files a/dgp/gui/ui/resources/meter_config.png and /dev/null differ diff --git a/dgp/gui/ui/resources/new_file.png b/dgp/gui/ui/resources/new_file.png index cc1f275..ab0badf 100644 Binary files a/dgp/gui/ui/resources/new_file.png and b/dgp/gui/ui/resources/new_file.png differ diff --git a/dgp/gui/ui/resources/open_in_new.png b/dgp/gui/ui/resources/open_in_new.png new file mode 100644 index 0000000..2ee464b Binary files /dev/null and b/dgp/gui/ui/resources/open_in_new.png differ diff --git a/dgp/gui/ui/resources/plane_icon.png b/dgp/gui/ui/resources/plane_icon.png deleted file mode 100644 index 863f550..0000000 Binary files a/dgp/gui/ui/resources/plane_icon.png and /dev/null differ diff --git a/dgp/gui/ui/resources/plot_line.png b/dgp/gui/ui/resources/plot_line.png new file mode 100644 index 0000000..62723bc Binary files /dev/null and b/dgp/gui/ui/resources/plot_line.png differ diff --git a/dgp/gui/ui/resources/project_tree.png b/dgp/gui/ui/resources/project_tree.png new file mode 100644 index 0000000..cbcca1b Binary files /dev/null and b/dgp/gui/ui/resources/project_tree.png differ diff --git a/dgp/gui/ui/resources/resources.qrc b/dgp/gui/ui/resources/resources.qrc index 1a277ac..87abf95 100644 --- a/dgp/gui/ui/resources/resources.qrc +++ b/dgp/gui/ui/resources/resources.qrc @@ -1,17 +1,34 @@ - AutosizeStretch_16x.png + open_in_new.png + select.png + console.png + chevron_left.png + chevron_up.png + dgp_simple.png + dgp_icon.png + dgp_icon_large.png + grid_off.png + grid_on.png + settings.png + info.png + help_outline.png + delete.png + line_mode.png + plot_line.png folder_open.png - meter_config.png + autosize.png new_file.png + sensor.png save_project.png - gps_icon.png grav_icon.png + location.png dgs_icon.xpm - boat_icon.png - plane_icon.png - tree-view/3x/chevron-down@3x.png - tree-view/3x/chevron-right@3x.png + project_tree.png + boat.png + chevron_down.png + flight.png + chevron_right.png geoid.png diff --git a/dgp/gui/ui/resources/save_project.png b/dgp/gui/ui/resources/save_project.png index c715901..f57f504 100644 Binary files a/dgp/gui/ui/resources/save_project.png and b/dgp/gui/ui/resources/save_project.png differ diff --git a/dgp/gui/ui/resources/select.png b/dgp/gui/ui/resources/select.png new file mode 100644 index 0000000..aa2b345 Binary files /dev/null and b/dgp/gui/ui/resources/select.png differ diff --git a/dgp/gui/ui/resources/sensor.png b/dgp/gui/ui/resources/sensor.png new file mode 100644 index 0000000..81364e2 Binary files /dev/null and b/dgp/gui/ui/resources/sensor.png differ diff --git a/dgp/gui/ui/resources/settings.png b/dgp/gui/ui/resources/settings.png new file mode 100644 index 0000000..e3ea7ef Binary files /dev/null and b/dgp/gui/ui/resources/settings.png differ diff --git a/dgp/gui/ui/resources/time_line.png b/dgp/gui/ui/resources/time_line.png new file mode 100644 index 0000000..3d68973 Binary files /dev/null and b/dgp/gui/ui/resources/time_line.png differ diff --git a/dgp/gui/ui/resources/tree-view/1x/Asset 1.png b/dgp/gui/ui/resources/tree-view/1x/Asset 1.png deleted file mode 100644 index d2f0f6a..0000000 Binary files a/dgp/gui/ui/resources/tree-view/1x/Asset 1.png and /dev/null differ diff --git a/dgp/gui/ui/resources/tree-view/2x/Asset 1@2x.png b/dgp/gui/ui/resources/tree-view/2x/Asset 1@2x.png deleted file mode 100644 index 8b9ddd7..0000000 Binary files a/dgp/gui/ui/resources/tree-view/2x/Asset 1@2x.png and /dev/null differ diff --git a/dgp/gui/ui/resources/tree-view/2x/chevron-down@2x.png b/dgp/gui/ui/resources/tree-view/2x/chevron-down@2x.png deleted file mode 100644 index 8b9ddd7..0000000 Binary files a/dgp/gui/ui/resources/tree-view/2x/chevron-down@2x.png and /dev/null differ diff --git a/dgp/gui/ui/resources/tree-view/2x/chevron-right@2x.png b/dgp/gui/ui/resources/tree-view/2x/chevron-right@2x.png deleted file mode 100644 index 5ab1d17..0000000 Binary files a/dgp/gui/ui/resources/tree-view/2x/chevron-right@2x.png and /dev/null differ diff --git a/dgp/gui/ui/resources/tree-view/3x/chevron-down@3x.png b/dgp/gui/ui/resources/tree-view/3x/chevron-down@3x.png deleted file mode 100644 index f333b52..0000000 Binary files a/dgp/gui/ui/resources/tree-view/3x/chevron-down@3x.png and /dev/null differ diff --git a/dgp/gui/ui/resources/tree-view/3x/chevron-right@3x.png b/dgp/gui/ui/resources/tree-view/3x/chevron-right@3x.png deleted file mode 100644 index 1c3f4f1..0000000 Binary files a/dgp/gui/ui/resources/tree-view/3x/chevron-right@3x.png and /dev/null differ diff --git a/dgp/gui/ui/resources/tree-view/ExpandChevronDown_16x.png b/dgp/gui/ui/resources/tree-view/ExpandChevronDown_16x.png deleted file mode 100644 index 36be56b..0000000 Binary files a/dgp/gui/ui/resources/tree-view/ExpandChevronDown_16x.png and /dev/null differ diff --git a/dgp/gui/ui/resources/tree-view/ExpandChevronDown_16x.svg b/dgp/gui/ui/resources/tree-view/ExpandChevronDown_16x.svg deleted file mode 100644 index d892b2d..0000000 --- a/dgp/gui/ui/resources/tree-view/ExpandChevronDown_16x.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/dgp/gui/ui/resources/tree-view/ExpandChevronRight_16x.png b/dgp/gui/ui/resources/tree-view/ExpandChevronRight_16x.png deleted file mode 100644 index 47b2690..0000000 Binary files a/dgp/gui/ui/resources/tree-view/ExpandChevronRight_16x.png and /dev/null differ diff --git a/dgp/gui/ui/resources/tree-view/ExpandChevronRight_16x.svg b/dgp/gui/ui/resources/tree-view/ExpandChevronRight_16x.svg deleted file mode 100644 index 1915fc8..0000000 --- a/dgp/gui/ui/resources/tree-view/ExpandChevronRight_16x.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/dgp/gui/ui/resources/tree-view/ExpandChevronRight_lg_16x.png b/dgp/gui/ui/resources/tree-view/ExpandChevronRight_lg_16x.png deleted file mode 100644 index dea77f9..0000000 Binary files a/dgp/gui/ui/resources/tree-view/ExpandChevronRight_lg_16x.png and /dev/null differ diff --git a/dgp/gui/ui/resources/tree-view/ExpandChevronRight_lg_16x.svg b/dgp/gui/ui/resources/tree-view/ExpandChevronRight_lg_16x.svg deleted file mode 100644 index 731598c..0000000 --- a/dgp/gui/ui/resources/tree-view/ExpandChevronRight_lg_16x.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/dgp/gui/ui/resources/tree-view/branch-closed.png b/dgp/gui/ui/resources/tree-view/branch-closed.png deleted file mode 100644 index 213ffdd..0000000 Binary files a/dgp/gui/ui/resources/tree-view/branch-closed.png and /dev/null differ diff --git a/dgp/gui/ui/resources/tree-view/branch-open.png b/dgp/gui/ui/resources/tree-view/branch-open.png deleted file mode 100644 index e8cad95..0000000 Binary files a/dgp/gui/ui/resources/tree-view/branch-open.png and /dev/null differ diff --git a/dgp/gui/ui/transform_tab_widget.ui b/dgp/gui/ui/transform_tab_widget.ui index e56a21b..a080bec 100644 --- a/dgp/gui/ui/transform_tab_widget.ui +++ b/dgp/gui/ui/transform_tab_widget.ui @@ -6,12 +6,12 @@ 0 0 - 622 - 500 + 298 + 450 - + 1 0 diff --git a/dgp/gui/utils.py b/dgp/gui/utils.py index ba096f2..eb8d082 100644 --- a/dgp/gui/utils.py +++ b/dgp/gui/utils.py @@ -1,15 +1,17 @@ # -*- coding: utf-8 -*- - +import json import logging from pathlib import Path -from typing import Union, Callable +from typing import Callable from PyQt5.QtCore import QThread, pyqtSignal, pyqtBoundSignal +from dgp.core.models.project import GravityProject, AirborneProject from dgp.core.oid import OID __all__ = ['LOG_FORMAT', 'LOG_COLOR_MAP', 'LOG_LEVEL_MAP', 'ConsoleHandler', - 'ProgressEvent', 'ThreadedFunction', 'clear_signal'] + 'ProgressEvent', 'ThreadedFunction', 'clear_signal', + 'load_project_from_path'] LOG_FORMAT = logging.Formatter(fmt="%(asctime)s:%(levelname)s - %(module)s:" "%(funcName)s :: %(message)s", @@ -19,6 +21,9 @@ LOG_LEVEL_MAP = {'debug': logging.DEBUG, 'info': logging.INFO, 'warning': logging.WARNING, 'error': logging.ERROR, 'critical': logging.CRITICAL} +_loaders = {GravityProject.__name__: GravityProject, + AirborneProject.__name__: AirborneProject} + _log = logging.getLogger(__name__) @@ -95,27 +100,54 @@ def run(self): res = self._functor(*self._args) self.result.emit(res) except Exception as e: - _log.exception(f"Exception executing {self.__name__}") + _log.exception(f"Exception executing {self._functor!r}") -def get_project_file(path: Path) -> Union[Path, None]: - """ - Attempt to retrieve a project file (*.d2p) from the given dir path, - otherwise signal failure by returning False. +def load_project_from_path(path: Path) -> GravityProject: + """Search a directory path for a valid DGP json file, then load the project + using the appropriate class loader. + + Any discovered .json files are loaded and parsed using a naive JSON loader, + the top level object is then inspected for an `_type` attribute, which + determines the project loader to use. + + The project's path attribute is updated to the path where it was loaded from + upon successful decoding. This is to ensure any relative paths encoded in + the project do not break if the project's directory has been moved/renamed. Parameters ---------- - path : Path - Directory path to search for DGP project files + path: :class:`pathlib.Path` + Directory path which contains a valid DGP project .json file. + If the path specified is not a directory, the parent is automatically + used + + Raises + ------ + :exc:`FileNotFoundError` + If supplied `path` does not exist, or + If no valid project JSON file could be loaded from the path + - Returns - ------- - Path : absolute path to DGP JSON file if found, else None + ToDo: Use QLockFile to try and lock the project json file for exclusive use """ - # TODO: Read JSON and check for presence of a magic attribute that marks a project file - for child in sorted(path.glob('*.json')): - return child.resolve() + if not path.exists(): + raise FileNotFoundError(f'Non-existent path supplied {path!s}') + if not path.is_dir(): + path = path.parent + + for child in path.glob('*.json'): + with child.open('r') as fd: + raw_str = fd.read() + raw_json: dict = json.loads(raw_str) + + loader = _loaders.get(raw_json.get('_type', None), None) + if loader is not None: + project = loader.from_json(raw_str) + project.path = path + return project + raise FileNotFoundError(f'No valid DGP JSON file could be loaded from {path!s}') def clear_signal(signal: pyqtBoundSignal): diff --git a/dgp/gui/widgets/channel_control_widgets.py b/dgp/gui/widgets/channel_control_widgets.py new file mode 100644 index 0000000..1cbdbaa --- /dev/null +++ b/dgp/gui/widgets/channel_control_widgets.py @@ -0,0 +1,445 @@ +# -*- coding: utf-8 -*- +import itertools +from functools import partial +from typing import List, Dict, Tuple +from weakref import WeakValueDictionary + +from PyQt5.QtCore import Qt, pyqtSignal, QModelIndex, QAbstractItemModel, QSize, QPoint +from PyQt5.QtGui import QStandardItem, QStandardItemModel, QMouseEvent, QColor, QPalette +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QListView, QMenu, QAction, + QSizePolicy, QStyledItemDelegate, + QStyleOptionViewItem, QHBoxLayout, QLabel, + QColorDialog, QToolButton, QFrame, QComboBox) +from pandas import Series +from pyqtgraph import PlotDataItem + +from dgp.core import Icon, OID +from dgp.gui.plotting.backends import GridPlotWidget, Axis, LINE_COLORS + +__all__ = ['ChannelController', 'ChannelItem'] + + +class ColorPicker(QLabel): + """ColorPicker creates a colored label displaying its current color value + + Clicking on the picker launches a QColorDialog, allowing the user to choose + a color. + + Parameters + ---------- + color : QColor, optional + Specify the initial color value of the color picker + parent : QWidget, optional + + """ + sigColorChanged = pyqtSignal(object) + + def __init__(self, color: QColor = QColor(), parent=None): + super().__init__(parent) + self.setAutoFillBackground(True) + self.setSizePolicy(QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)) + self.setToolTip("Customize channel line color") + self._color = color + self._update() + + @property + def color(self) -> QColor: + return self._color + + def mouseReleaseEvent(self, event: QMouseEvent): + color: QColor = QColorDialog.getColor(self._color, parent=self) + if color.isValid(): + self._color = color + self.sigColorChanged.emit(self._color) + self._update() + + def sizeHint(self): + return QSize(30, 30) + + def _update(self): + """Updates the background color for display""" + palette: QPalette = self.palette() + palette.setColor(self.backgroundRole(), self._color) + self.setPalette(palette) + + +class DataChannelEditor(QFrame): + """This object defines the widget displayed when a data channel is selected + within the ChannelController listr view. + + This widget provides controls enabling a user to select which plot and axis + a channel is plotted on, and to set the visibility and color of the channel. + + """ + SIZE = QSize(140, 35) + + def __init__(self, item: 'ChannelItem', rows=1, parent=None): + super().__init__(parent, flags=Qt.Widget) + self.setFrameStyle(QFrame.Box) + self.setLineWidth(1) + self.setAutoFillBackground(True) + + self._item = item + + layout = QHBoxLayout(self) + layout.setContentsMargins(2, 2, 2, 2) + layout.setSpacing(1) + sp_btn = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.MinimumExpanding) + sp_combo = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.MinimumExpanding) + + self._label = QLabel(item.name) + self._picker = ColorPicker(color=item.line_color, parent=self) + self._picker.sigColorChanged.connect(item.set_color) + + # Plot Row Selection ComboBox + + self._row_cb = QComboBox() + self._row_cb.setToolTip("Plot channel on selected row") + self._row_cb.setSizePolicy(sp_combo) + for i in range(rows): + self._row_cb.addItem(str(i), i) + self._row_cb.setCurrentIndex(item.target_row) + self._row_cb.currentIndexChanged.connect(self.change_row) + + # Left/Right Axis Controls + self._left = QToolButton() + self._left.setCheckable(False) + self._left.setToolTip("Plot channel on left y-axis") + self._left.setIcon(Icon.ARROW_LEFT.icon()) + self._left.setSizePolicy(sp_btn) + self._left.clicked.connect(partial(self.change_axis, Axis.LEFT)) + self._right = QToolButton() + self._right.setCheckable(False) + self._right.setToolTip("Plot channel on right y-axis") + self._right.setIcon(Icon.ARROW_RIGHT.icon()) + self._right.setSizePolicy(sp_btn) + self._right.clicked.connect(partial(self.change_axis, Axis.RIGHT)) + + # Channel Settings ToolButton + self._settings = QToolButton() + self._settings.setSizePolicy(sp_btn) + self._settings.setIcon(Icon.SETTINGS.icon()) + + layout.addWidget(self._label) + layout.addSpacing(5) + layout.addWidget(self._picker) + layout.addSpacing(2) + layout.addWidget(self._row_cb) + layout.addSpacing(5) + layout.addWidget(self._left) + layout.addWidget(self._right) + layout.addWidget(self._settings) + + def toggle_axis(self, axis: Axis, checked: bool): + pass + + def change_axis(self, axis): + + self._item.set_axis(axis, emit=False) + if self._item.checkState() == Qt.Checked: + self._item.update() + else: + self._item.setCheckState(Qt.Checked) + + def change_row(self, index): + row: int = self._row_cb.currentData(Qt.UserRole) + self._item.set_row(row) + + def mouseDoubleClickEvent(self, event: QMouseEvent): + self._item.setCheckState(Qt.Unchecked if self._item.visible else Qt.Checked) + + +class ChannelDelegate(QStyledItemDelegate): + def __init__(self, rows=1, parent=None): + super().__init__(parent=parent) + self._rows = rows + + def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, + index: QModelIndex): + item = index.model().itemFromIndex(index) + editor = DataChannelEditor(item, self._rows, parent) + + return editor + + def setModelData(self, editor: QWidget, model: QAbstractItemModel, + index: QModelIndex): + """Do nothing, editor does not directly mutate model data""" + pass + + def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex): + return DataChannelEditor.SIZE + + +class ChannelItem(QStandardItem): + """The ChannelItem defines the UI representation of a plotable data channel + + ChannelItems maintain the desired state of the channel in relation to its + visibility, line color, plot axis, and plot row/column. It is the + responsibility of the owning controller to act on state changes of the + channel item. + + The itemChanged signal is emitted (via the QStandardItemModel owner) by the + ChannelItem whenever its internal state has been updated. + + Parameters + ---------- + name: str + Display name for the channel + color : QColor, optional + Optional base color for this channel item + + Notes + ----- + Setter methods are used instead of property setters in order to facilitate + signal connections, or setting of properties from within a lambda expression + + """ + _base_color = QColor(Qt.white) + + def __init__(self, name: str, color=QColor()): + super().__init__() + self.setCheckable(True) + self.name = name + self._row = 0 + self._col = 0 + self._axis = Axis.LEFT + self._color = color + self.uid = OID(tag=name) + + self.update(emit=False) + + @property + def target_row(self): + return self._row + + def set_row(self, row, emit=True): + self._row = row + if emit: + self.update() + + @property + def target_axis(self): + return self._axis + + def set_axis(self, axis: Axis, emit=True): + self._axis = axis + if emit: + self.update() + + @property + def line_color(self) -> QColor: + return self._color + + def set_color(self, color: QColor, emit=True): + self._color = color + if emit: + self.update() + + @property + def visible(self) -> bool: + return self.checkState() == Qt.Checked + + def set_visible(self, visible: bool, emit=True): + self.setCheckState(Qt.Checked if visible else Qt.Unchecked) + if emit: + self.update() + + def update(self, emit=True): + if self.visible: + self.setText(f'{self.name} - {self.target_row} | {self.target_axis.value}') + self.setBackground(self.line_color) + else: + self.setText(f'{self.name}') + self.setBackground(self._base_color) + if emit: + self.emitDataChanged() + + def key(self) -> Tuple[int, int, Axis]: + return self._row, self._col, self._axis + + def __hash__(self): + return hash(self.uid) + + +class ChannelController(QWidget): + """The ChannelController widget is associated with a Plotter, e.g. a + :class:`GridPlotWidget`, and provides an interface for a user to select and + plot any of the various :class:`pandas.Series` objects supplied to it. + + Parameters + ---------- + plotter : :class:`~dgp.gui.plotting.backends.GridPlotWidget` + *series : :class:`pandas.Series` + binary_series : List of :class:`pandas.Series`, optional + Optional list of series to be interpreted/grouped as binary data, e.g. + for status bits + parent : QWidget, optional + + """ + def __init__(self, plotter: GridPlotWidget, *series: Series, + binary_series: List[Series] = None, parent: QWidget = None): + super().__init__(parent, flags=Qt.Widget) + self.plotter = plotter + self.plotter.sigPlotCleared.connect(self._channels_cleared) + self.setSizePolicy(QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)) + self._layout = QVBoxLayout(self) + + self._model = QStandardItemModel() + self._model.itemChanged.connect(self.channel_changed) + self._binary_model = QStandardItemModel() + self._binary_model.itemChanged.connect(self.binary_changed) + + self._series: Dict[OID, Series] = {} + self._active: Dict[OID, PlotDataItem] = WeakValueDictionary() + self._indexes: Dict[OID, Tuple[int, int, Axis]] = {} + + self._colors = itertools.cycle(LINE_COLORS) + + # Define/configure List Views + series_delegate = ChannelDelegate(rows=self.plotter.rows, parent=self) + self.series_view = QListView(parent=self) + self.series_view.setMinimumWidth(250) + self.series_view.setUniformItemSizes(True) + self.series_view.setEditTriggers(QListView.SelectedClicked | + QListView.DoubleClicked | + QListView.CurrentChanged) + self.series_view.setItemDelegate(series_delegate) + self.series_view.setContextMenuPolicy(Qt.CustomContextMenu) + self.series_view.customContextMenuRequested.connect(self._context_menu) + self.series_view.setModel(self._model) + + self._layout.addWidget(self.series_view, stretch=2) + + self.binary_view = QListView(parent=self) + self.binary_view.setEditTriggers(QListView.NoEditTriggers) + self.binary_view.setUniformItemSizes(True) + self.binary_view.setModel(self._binary_model) + + self._status_label = QLabel("Status Channels") + self._layout.addWidget(self._status_label, alignment=Qt.AlignHCenter) + + self._layout.addWidget(self.binary_view, stretch=1) + + self.set_series(*series) + binary_series = binary_series or [] + self.set_binary_series(*binary_series) + + def set_series(self, *series, clear=True): + if clear: + self._model.clear() + + for s in series: + item = ChannelItem(s.name, QColor(next(self._colors))) + self._series[item.uid] = s + self._model.appendRow(item) + + def set_binary_series(self, *series, clear=True): + if clear: + self._binary_model.clear() + + for b in series: + item = QStandardItem(b.name) + item.uid = OID() + item.setCheckable(True) + self._series[item.uid] = b + self._binary_model.appendRow(item) + + def get_state(self): + active_state = {} + for uid, item in self._active.items(): + row, col, axis = self._indexes[uid] + active_state[item.name()] = row, col, axis.value + return active_state + + def restore_state(self, state: Dict[str, Tuple[int, int, str]]): + for i in range(self._model.rowCount()): + item: ChannelItem = self._model.item(i, 0) + if item.name in state: + key = state[item.name] + item.set_visible(True, emit=False) + item.set_row(key[0], emit=False) + item.set_axis(Axis(key[2]), emit=True) + + for i in range(self._binary_model.rowCount()): + item: QStandardItem = self._binary_model.item(i, 0) + if item.text() in state: + item.setCheckState(Qt.Checked) + + def channel_changed(self, item: ChannelItem): + item.update(emit=False) + if item.uid in self._active: # Channel is already somewhere on the plot + if not item.visible: + self._remove_series(item) + else: + self._update_series(item) + + elif item.visible: # Channel is not yet plotted + self._add_series(item) + series = self._series[item.uid] + line = self.plotter.add_series(series, item.target_row, + axis=item.target_axis) + self._active[item.uid] = line + else: # Item is not active, and its state is not visible (do nothing) + pass + + def _add_series(self, item: ChannelItem): + """Add a new series to the controls plotter""" + series = self._series[item.uid] + row = item.target_row + axis = item.target_axis + + line = self.plotter.add_series(series, row, col=0, axis=axis, + pen=item.line_color) + self._active[item.uid] = line + self._indexes[item.uid] = item.key() + + def _update_series(self, item: ChannelItem): + """Update paramters (color, axis, row) of an already plotted series""" + line = self._active[item.uid] + line.setPen(item.line_color) + + # Need to know the current axis and row of an _active line + if item.key() != self._indexes[item.uid]: + self._remove_series(item) + self._add_series(item) + + def _remove_series(self, item: ChannelItem): + line = self._active[item.uid] + self.plotter.remove_plotitem(line) + del self._indexes[item.uid] + + def _channels_cleared(self): + """Respond to plot notification that all lines have been cleared""" + for i in range(self._model.rowCount()): + item: ChannelItem = self._model.item(i) + item.set_visible(False, emit=False) + item.update(emit=False) + for i in range(self._binary_model.rowCount()): + item: QStandardItem = self._binary_model.item(i) + item.setCheckState(Qt.Unchecked) + + def binary_changed(self, item: QStandardItem): + if item.checkState() == Qt.Checked: + if item.uid in self._active: + return + else: + series = self._series[item.uid] + line = self.plotter.add_series(series, 1, 0, axis=Axis.RIGHT) + self._active[item.uid] = line + self._indexes[item.uid] = 1, 0, Axis.RIGHT + else: + try: + line = self._active[item.uid] + self.plotter.remove_plotitem(line) + except KeyError: + # Item may have already been deleted by the plot + pass + + def _context_menu(self, point: QPoint): + index: QModelIndex = self.series_view.indexAt(point) + if not index.isValid(): + # DEBUG + print("Providing general context menu (clear items)") + else: + # DEBUG + print(f'Providing menu for item {self._model.itemFromIndex(index).text()}') diff --git a/dgp/gui/widgets/channel_select_widget.py b/dgp/gui/widgets/channel_select_widget.py deleted file mode 100644 index 08cc3a9..0000000 --- a/dgp/gui/widgets/channel_select_widget.py +++ /dev/null @@ -1,123 +0,0 @@ -# -*- coding: utf-8 -*- -import functools -from typing import Union - -from PyQt5.QtCore import QObject, Qt, pyqtSignal, QModelIndex, QIdentityProxyModel -from PyQt5.QtGui import QStandardItem, QStandardItemModel, QContextMenuEvent -from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QListView, QMenu, QAction, - QSizePolicy, QPushButton) - - -class ChannelProxyModel(QIdentityProxyModel): - def __init__(self, parent=None): - super().__init__(parent=parent) - - def setSourceModel(self, model: QStandardItemModel): - super().setSourceModel(model) - - def insertColumns(self, p_int, p_int_1, parent=None, *args, **kwargs): - pass - - -class ChannelListView(QListView): - channel_plotted = pyqtSignal(int, QStandardItem) - channel_unplotted = pyqtSignal(QStandardItem) - - def __init__(self, nplots=1, parent=None): - super().__init__(parent) - self.setSizePolicy(QSizePolicy(QSizePolicy.Maximum, QSizePolicy.MinimumExpanding)) - self.setEditTriggers(QListView.NoEditTriggers) - self._n = nplots - self._actions = [] - - def setModel(self, model: QStandardItemModel): - super().setModel(model) - - def contextMenuEvent(self, event: QContextMenuEvent, *args, **kwargs): - index: QModelIndex = self.indexAt(event.pos()) - self._actions.clear() - item = self.model().itemFromIndex(index) - menu = QMenu(self) - for i in range(self._n): - action: QAction = QAction("Plot on %d" % i) - action.triggered.connect(functools.partial(self._plot_item, i, item)) - # action.setCheckable(True) - # action.setChecked(item.checkState()) - # action.toggled.connect(functools.partial(self._channel_toggled, item, i)) - self._actions.append(action) - menu.addAction(action) - - action_del: QAction = QAction("Clear from plot") - action_del.triggered.connect(functools.partial(self._unplot_item, item)) - menu.addAction(action_del) - - menu.exec_(event.globalPos()) - event.accept() - - def _channel_toggled(self, item: QStandardItem, plot: int, checked: bool): - print("item: %s in checkstate %s on plot: %d" % (item.data(Qt.DisplayRole), str(checked), plot)) - item.setCheckState(checked) - - def _plot_item(self, plot: int, item: QStandardItem): - print("Plotting %s on plot# %d" % (item.data(Qt.DisplayRole), plot)) - self.channel_plotted.emit(plot, item) - - def _unplot_item(self, item: QStandardItem): - self.channel_unplotted.emit(item) - - -class ChannelSelectWidget(QWidget): - """ - Working Notes: - Lets assume a channel can only be plotted once in total no matter how many plots - - Options - we can use check boxes, right-click context menu, or a table with 3 checkboxes (but 3 copies of the - channel?) - - Either the channel (QStandardItem) or the view needs to track its plotted state somehow - Perhaps we can use a QIdentityProxyModel to which we can add columns to without modifying - the source model. - - """ - channel_added = pyqtSignal(int, QStandardItem) - channel_removed = pyqtSignal(QStandardItem) - channels_cleared = pyqtSignal() - - def __init__(self, model: QStandardItemModel, plots: int = 1, parent: Union[QWidget, QObject] = None): - super().__init__(parent=parent, flags=Qt.Widget) - self._model = model - self._model.modelReset.connect(self.channels_cleared.emit) - self._model.rowsInserted.connect(self._rows_inserted) - self._model.itemChanged.connect(self._item_changed) - - self._view = ChannelListView(nplots=2, parent=self) - self._view.channel_plotted.connect(self.channel_added.emit) - self._view.channel_unplotted.connect(self.channel_removed.emit) - self._view.setModel(self._model) - - self._qpb_clear = QPushButton("Clear Channels") - self._qpb_clear.clicked.connect(self.channels_cleared.emit) - self._layout = QVBoxLayout(self) - self._layout.addWidget(self._view) - self._layout.addWidget(self._qpb_clear) - - - def _rows_inserted(self, parent: QModelIndex, first: int, last: int): - pass - # print("Rows have been inserted: %d to %d" % (first, last)) - - def _rows_removed(self, parent: QModelIndex, first: int, last: int): - pass - # print("Row has been removed: %d" % first) - - def _model_reset(self): - print("Model has been reset") - - def _item_changed(self, item: QStandardItem): - # Work only on single plot for now - if item.checkState(): - print("Plotting channel: %s" % item.data(Qt.DisplayRole)) - self.channel_added.emit(0, item) - else: - print("Removing channel: %s" % item.data(Qt.DisplayRole)) - self.channel_removed.emit(item) diff --git a/dgp/gui/workspaces/TransformTab.py b/dgp/gui/widgets/data_transform_widget.py similarity index 84% rename from dgp/gui/workspaces/TransformTab.py rename to dgp/gui/widgets/data_transform_widget.py index 4be32e8..fb0ad85 100644 --- a/dgp/gui/workspaces/TransformTab.py +++ b/dgp/gui/widgets/data_transform_widget.py @@ -1,21 +1,20 @@ # -*- coding: utf-8 -*- - -import logging import inspect +import logging from enum import Enum, auto from typing import List import pandas as pd -from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot +from PyQt5.QtCore import pyqtSignal, Qt, pyqtSlot from PyQt5.QtGui import QStandardItemModel, QStandardItem -from PyQt5.QtWidgets import QVBoxLayout, QWidget, QInputDialog, QTextEdit +from PyQt5.QtWidgets import QWidget, QTextEdit -from dgp.core.controllers.dataset_controller import DataSegmentController, DataSetController -from dgp.core.controllers.flight_controller import FlightController -from dgp.lib.transform.transform_graphs import SyncGravity, AirbornePost, TransformGraph -from dgp.gui.plotting.plotters import TransformPlot, AxisFormatter -from . import TaskTab -from ..ui.transform_tab_widget import Ui_TransformInterface +from dgp.core.controllers.dataset_controller import DataSetController, DataSegmentController +from dgp.gui.plotting.backends import AxisFormatter +from dgp.gui.plotting.plotters import TransformPlot +from dgp.gui.ui.transform_tab_widget import Ui_TransformInterface +from dgp.lib.transform.graph import TransformGraph +from dgp.lib.transform.transform_graphs import AirbornePost try: from pygments import highlight @@ -39,13 +38,12 @@ class TransformWidget(QWidget, Ui_TransformInterface): LATITUDE = 0x0102 LONGITUDE = 0x103 - def __init__(self, flight: FlightController): + def __init__(self, dataset: DataSetController, plotter: TransformPlot): super().__init__() self.setupUi(self) self.log = logging.getLogger(__name__) - self._flight = flight - self._dataset: DataSetController = flight.active_child - self._plot = TransformPlot() + self._dataset: DataSetController = dataset + self._plot = plotter self._mode = _Mode.NORMAL self._segment_indexes = {} @@ -93,11 +91,6 @@ def __init__(self, flight: FlightController): self.qpb_toggle_mode.clicked.connect(self._mode_toggled) self.qte_source_browser.setReadOnly(True) self.qte_source_browser.setLineWrapMode(QTextEdit.NoWrap) - self.qvbl_plot_layout = QVBoxLayout() - self._toolbar = self._plot.get_toolbar(self) - self.qvbl_plot_layout.addWidget(self._toolbar, alignment=Qt.AlignRight) - self.qvbl_plot_layout.addWidget(self._plot) - self.hlayout.addLayout(self.qvbl_plot_layout) @property def xaxis_index(self) -> int: @@ -268,25 +261,3 @@ def execute_transform(self): del self._result self._result = graph.result_df() self.result.emit() - - -class TransformTab(TaskTab): - """Sub-tab displayed within Flight tab interface. Displays interface for selecting - Transform chains and plots for displaying the resultant data sets. - """ - _name = "Transform" - - def __init__(self, label: str, flight): - super().__init__(label, flight) - - self._layout = QVBoxLayout() - self._layout.addWidget(TransformWidget(flight)) - self.setLayout(self._layout) - - def data_modified(self, action: str, dsrc): - """Slot: Called when a DataSource has been added/removed from the - Flight this tab/workspace is associated with.""" - if action.lower() == 'add': - return - elif action.lower() == 'remove': - return diff --git a/dgp/gui/workspace.py b/dgp/gui/widgets/workspace_widget.py similarity index 63% rename from dgp/gui/workspace.py rename to dgp/gui/widgets/workspace_widget.py index c28bfbb..ef8ecfa 100644 --- a/dgp/gui/workspace.py +++ b/dgp/gui/widgets/workspace_widget.py @@ -1,52 +1,11 @@ # -*- coding: utf-8 -*- - -import logging - +import PyQt5.QtWidgets as QtWidgets from PyQt5.QtGui import QContextMenuEvent, QKeySequence from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QWidget, QVBoxLayout, QTabWidget, QAction -import PyQt5.QtWidgets as QtWidgets +from PyQt5.QtWidgets import QAction -from dgp.core.controllers.controller_interfaces import IBaseController -from dgp.core.controllers.flight_controller import FlightController from dgp.core.oid import OID -from .workspaces import PlotTab -from .workspaces import TransformTab - - -class WorkspaceTab(QWidget): - """Top Level Tab created for each Flight object open in the workspace""" - - def __init__(self, flight: FlightController, parent=None, flags=0, **kwargs): - super().__init__(parent=parent, flags=Qt.Widget) - self.log = logging.getLogger(__name__) - self._root: IBaseController = flight - self._layout = QVBoxLayout(self) - self._setup_tasktabs() - - def _setup_tasktabs(self): - # Define Sub-Tabs within Flight space e.g. Plot, Transform, Maps - self._tasktabs = QTabWidget() - self._tasktabs.setTabPosition(QTabWidget.West) - self._layout.addWidget(self._tasktabs) - - self._plot_tab = PlotTab(label="Plot", flight=self._root) - self._tasktabs.addTab(self._plot_tab, "Plot") - - self._transform_tab = TransformTab("Transforms", self._root) - self._tasktabs.addTab(self._transform_tab, "Transforms") - - self._tasktabs.setCurrentIndex(0) - self._plot_tab.update() - - @property - def uid(self) -> OID: - """Return the underlying Flight's UID""" - return self._root.uid - - @property - def root(self) -> IBaseController: - return self._root +from ..workspaces.base import WorkspaceTab class _WorkspaceTabBar(QtWidgets.QTabBar): @@ -101,18 +60,26 @@ def _tab_left(self, *args): self.setCurrentIndex(index) -class MainWorkspace(QtWidgets.QTabWidget): +class WorkspaceWidget(QtWidgets.QTabWidget): """Custom QTabWidget promoted in main_window.ui supporting a custom TabBar which enables the attachment of custom event actions e.g. right - click context-menus for the tab bar buttons.""" + click context-menus for the tab bar buttons. + + """ def __init__(self, parent=None): super().__init__(parent=parent) self.setTabBar(_WorkspaceTabBar()) - self.tabCloseRequested.connect(self.removeTab) + self.tabCloseRequested.connect(self.close_tab_by_index) def widget(self, index: int) -> WorkspaceTab: return super().widget(index) + def addTab(self, tab: WorkspaceTab, label: str = None): + if label is None: + label = tab.title + super().addTab(tab, label) + self.setCurrentWidget(tab) + # Utility functions for referencing Tab widgets by OID def get_tab(self, uid: OID): @@ -126,8 +93,15 @@ def get_tab_index(self, uid: OID): if uid == self.widget(i).uid: return i + def close_tab_by_index(self, index: int): + tab = self.widget(index) + tab.close() + self.removeTab(index) + def close_tab(self, uid: OID): + tab = self.get_tab(uid) + if tab is not None: + tab.close() index = self.get_tab_index(uid) if index is not None: self.removeTab(index) - diff --git a/dgp/gui/workspaces/PlotTab.py b/dgp/gui/workspaces/PlotTab.py deleted file mode 100644 index 34b4504..0000000 --- a/dgp/gui/workspaces/PlotTab.py +++ /dev/null @@ -1,102 +0,0 @@ -# -*- coding: utf-8 -*- -import logging - -import pandas as pd -from PyQt5.QtCore import Qt -from PyQt5.QtGui import QStandardItem -from PyQt5.QtWidgets import QVBoxLayout, QHBoxLayout, QDockWidget, QSizePolicy, QAction - -from dgp.core import StateAction, Icon -from dgp.gui.widgets.channel_select_widget import ChannelSelectWidget -from dgp.core.controllers.flight_controller import FlightController -from dgp.gui.plotting.plotters import LineUpdate, LineSelectPlot -from dgp.gui.plotting.backends import Axis -from .TaskTab import TaskTab - - -class PlotTab(TaskTab): - """Sub-tab displayed within Flight tab interface. Displays canvas for - plotting data series. - - Parameters - ---------- - label : str - flight : FlightController - - """ - - def __init__(self, label: str, flight: FlightController, **kwargs): - # TODO: It may make more sense to associate a DataSet with the plot vs a Flight - super().__init__(label, root=flight, **kwargs) - self.log = logging.getLogger(__name__) - self._dataset = flight.active_child - - self._plot = LineSelectPlot(rows=2) - self._plot.sigSegmentChanged.connect(self._on_modified_line) - - for segment in self._dataset.segments: - group = self._plot.add_segment(segment.get_attr('start'), - segment.get_attr('stop'), - segment.get_attr('label'), - segment.uid, emit=False) - segment.add_reference(group) - - # Create/configure the tab layout/widgets/controls - qhbl_main_layout = QHBoxLayout() - qvbl_plot_layout = QVBoxLayout() - qhbl_main_layout.addItem(qvbl_plot_layout) - self.toolbar = self._plot.get_toolbar(self) - # self.toolbar.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - qvbl_plot_layout.addWidget(self.toolbar, alignment=Qt.AlignLeft) - qvbl_plot_layout.addWidget(self._plot) - - # Toggle control to hide/show data channels dock - qa_channel_toggle = QAction(Icon.PLOT_LINE.icon(), "Data Channels", self) - qa_channel_toggle.setCheckable(True) - qa_channel_toggle.setChecked(True) - self.toolbar.addAction(qa_channel_toggle) - - # Load data channel selection widget - channel_widget = ChannelSelectWidget(self._dataset.series_model) - channel_widget.channel_added.connect(self._channel_added) - channel_widget.channel_removed.connect(self._channel_removed) - channel_widget.channels_cleared.connect(self._plot.clear) - - dock_widget = QDockWidget("Channels") - dock_widget.setFeatures(QDockWidget.NoDockWidgetFeatures) - dock_widget.setSizePolicy(QSizePolicy(QSizePolicy.Maximum, - QSizePolicy.Preferred)) - dock_widget.setWidget(channel_widget) - qa_channel_toggle.toggled.connect(dock_widget.setVisible) - qhbl_main_layout.addWidget(dock_widget) - self.setLayout(qhbl_main_layout) - - def _channel_added(self, row: int, item: QStandardItem): - series: pd.Series = item.data(Qt.UserRole) - if series.max(skipna=True) < 1000: - axis = Axis.RIGHT - else: - axis = Axis.LEFT - self._plot.add_series(item.data(Qt.UserRole), row, axis=axis) - - def _channel_removed(self, item: QStandardItem): - series: pd.Series = item.data(Qt.UserRole) - indexes = self._plot.find_series(series.name) - for index in indexes: - self._plot.remove_series(*index) - - def _on_modified_line(self, update: LineUpdate): - if update.action is StateAction.DELETE: - self._dataset.remove_segment(update.uid) - return - - start: pd.Timestamp = update.start - stop: pd.Timestamp = update.stop - assert isinstance(start, pd.Timestamp) - assert isinstance(stop, pd.Timestamp) - - if update.action is StateAction.UPDATE: - self._dataset.update_segment(update.uid, start, stop, update.label) - else: - seg = self._dataset.add_segment(update.uid, start, stop, update.label) - seg.add_reference(self._plot.get_segment(seg.uid)) diff --git a/dgp/gui/workspaces/TaskTab.py b/dgp/gui/workspaces/TaskTab.py deleted file mode 100644 index a58f470..0000000 --- a/dgp/gui/workspaces/TaskTab.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -import logging - -from PyQt5.QtWidgets import QWidget - -from dgp.core.oid import OID -from dgp.core.controllers.controller_interfaces import IBaseController - - -class TaskTab(QWidget): - """Base Workspace Tab Widget - Subclass to specialize function - Provides interface to root tab object e.g. Flight, DataSet and a property - to access the UID associated with this tab (via the root object) - - Parameters - ---------- - label : str - root : :class:`IBaseController` - Root project object encapsulated by this tab - parent : QWidget, Optional - Parent widget - kwargs - Key-word arguments passed to QWidget constructor - - """ - def __init__(self, label: str, root: IBaseController, parent=None, **kwargs): - super().__init__(parent, **kwargs) - self.log = logging.getLogger(__name__) - self.label = label - self._root = root - - @property - def uid(self) -> OID: - return self._root.uid - - @property - def root(self) -> IBaseController: - """Return the root data object/controller associated with this tab.""" - return self._root diff --git a/dgp/gui/workspaces/__init__.py b/dgp/gui/workspaces/__init__.py index df7aa44..eba49b9 100644 --- a/dgp/gui/workspaces/__init__.py +++ b/dgp/gui/workspaces/__init__.py @@ -1,7 +1,20 @@ # -*- coding: utf-8 -*- -from .TaskTab import TaskTab -from .PlotTab import PlotTab -from .TransformTab import TransformTab +from dgp.core.controllers.controller_interfaces import IBaseController +from .project import ProjectTab, AirborneProjectController +from .flight import FlightTab, FlightController +from .dataset import DataSetTab, DataSetController -__all__ = ['TaskTab', 'PlotTab', 'TransformTab'] +__all__ = ['ProjectTab', 'FlightTab', 'DataSetTab', 'tab_factory'] + +# Note: Disabled ProjectTab/FlightTab until they are implemented +_tabmap = { + # AirborneProjectController: ProjectTab, + FlightController: FlightTab, + DataSetController: DataSetTab +} + + +def tab_factory(controller: IBaseController): + """Return the workspace tab constructor for the given controller type""" + return _tabmap.get(controller.__class__, None) diff --git a/dgp/gui/workspaces/base.py b/dgp/gui/workspaces/base.py new file mode 100644 index 0000000..8b0f8d1 --- /dev/null +++ b/dgp/gui/workspaces/base.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +import json + +from PyQt5.QtCore import pyqtSignal +from PyQt5.QtGui import QCloseEvent +from PyQt5.QtWidgets import QWidget + +from dgp.core import OID +from dgp.gui import settings + +__all__ = ['WorkspaceTab', 'SubTab'] + + +class WorkspaceTab(QWidget): + @property + def uid(self) -> OID: + raise NotImplementedError + + @property + def title(self) -> str: + raise NotImplementedError + + @property + def state_key(self) -> str: + return f'Workspace/{self.uid!s}' + + def get_state(self) -> dict: + key = f'Workspace/{self.uid!s}' + return json.loads(settings().value(key, '{}')) + + def save_state(self, state=None) -> None: + """Save/dump the current state of the WorkspaceTab + + This method is called when the tab is closed, and should be used to + retrieve and store the state of the WorkspaceTab and its sub-tabs or + other components. + + Override this method to provide state handling for a WorkspaceTab + """ + _jsons = json.dumps(state) + settings().setValue(self.state_key, _jsons) + + def closeEvent(self, event: QCloseEvent): + self.save_state() + event.accept() + + +class SubTab(QWidget): + sigLoaded = pyqtSignal(object) + + def get_state(self): + """Get a representation of the current state of the SubTab + + This method should be overridden by sub-classes of SubTab, in order to + provide a tab/context specific state representation. + + The returned dictionary and all of its values (including nested dicts) + must be serializable by the default Python json serializer. + + The state dictionary returned by this method will be supplied to the + restore_state method when the tab is loaded. + + Returns + ------- + dict + dict of JSON serializable key: value pairs + + """ + return {} + + def restore_state(self, state: dict) -> None: + """Restore the tab to reflect the saved state supplied to this method + + Parameters + ---------- + state : dict + Dictionary containing the state representation for this object. As + produced by :meth:`get_state` + + """ + pass diff --git a/dgp/gui/workspaces/dataset.py b/dgp/gui/workspaces/dataset.py new file mode 100644 index 0000000..d7ff9bd --- /dev/null +++ b/dgp/gui/workspaces/dataset.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +import pandas as pd +from PyQt5 import QtWidgets +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QAction, QSizePolicy + +from dgp.core import StateAction, Icon +from dgp.core.controllers.dataset_controller import DataSetController +from dgp.gui.plotting.helpers import LineUpdate +from dgp.gui.plotting.plotters import LineSelectPlot, TransformPlot +from dgp.gui.widgets.channel_control_widgets import ChannelController +from dgp.gui.widgets.data_transform_widget import TransformWidget +from dgp.gui.utils import ThreadedFunction +from .base import WorkspaceTab, SubTab + + +class SegmentSelectTab(SubTab): + """Sub-tab displayed within the DataSetTab Workspace""" + def __init__(self, dataset: DataSetController, parent=None): + super().__init__(parent=parent, flags=Qt.Widget) + self.dataset: DataSetController = dataset + self._state = {} + + self._plot = LineSelectPlot(rows=2) + self._plot.sigSegmentChanged.connect(self._on_modified_segment) + + for segment in self.dataset.segments: + group = self._plot.add_segment(segment.get_attr('start'), + segment.get_attr('stop'), + segment.get_attr('label'), + segment.uid, emit=False) + segment.add_reference(group) + + # Create/configure the tab layout/widgets/controls + qhbl_main_layout = QtWidgets.QHBoxLayout(self) + qvbl_plot_layout = QtWidgets.QVBoxLayout() + qhbl_main_layout.addLayout(qvbl_plot_layout) + self.toolbar = self._plot.get_toolbar(self) + qvbl_plot_layout.addWidget(self.toolbar, alignment=Qt.AlignLeft) + qvbl_plot_layout.addWidget(self._plot) + + self.controller = ChannelController(self._plot, parent=self) + qhbl_main_layout.addWidget(self.controller) + + # Toggle control to hide/show data channels dock + qa_channel_toggle = QAction(Icon.PLOT_LINE.icon(), "Data Channels", self) + qa_channel_toggle.setCheckable(True) + qa_channel_toggle.setChecked(True) + qa_channel_toggle.toggled.connect(self.controller.setVisible) + self.toolbar.addAction(qa_channel_toggle) + + # Load data channel selection widget + th = ThreadedFunction(self.dataset.dataframe, parent=self) + th.result.connect(self._dataframe_loaded) + th.start() + + def _dataframe_loaded(self, df): + data_cols = ('gravity', 'long_accel', 'cross_accel', 'beam', 'temp', + 'pressure', 'Etemp', 'gps_week', 'gps_sow', 'lat', 'long', + 'ell_ht') + cols = [df[col] for col in df if col in data_cols] + stat_cols = [df[col] for col in df if col not in data_cols] + self.controller.set_series(*cols) + self.controller.set_binary_series(*stat_cols) + self.sigLoaded.emit(self) + + def get_state(self): + """Get the current state of the dataset workspace + + The 'state' of the workspace refers to things which we would like the + ability to restore based on user preferences when they next load the tab + + This may include which channels are plotted, and on which plot/axis. + This may also include the plot configuration, e.g. how many rows/columns + and perhaps visibility settings (grid alpha, line alpha, axis display) + + Returns + ------- + dict + Dictionary of state key/values, possibly nested + + """ + return self.controller.get_state() + + def restore_state(self, state): + self.controller.restore_state(state) + + def _on_modified_segment(self, update: LineUpdate): + if update.action is StateAction.DELETE: + self.dataset.remove_segment(update.uid) + return + + start: pd.Timestamp = update.start + stop: pd.Timestamp = update.stop + assert isinstance(start, pd.Timestamp) + assert isinstance(stop, pd.Timestamp) + + if update.action is StateAction.UPDATE: + self.dataset.update_segment(update.uid, start, stop, update.label) + else: + seg = self.dataset.add_segment(update.uid, start, stop, update.label) + seg.add_reference(self._plot.get_segment(seg.uid)) + + +class DataTransformTab(SubTab): + def __init__(self, dataset: DataSetController, parent=None): + super().__init__(parent=parent, flags=Qt.Widget) + layout = QtWidgets.QHBoxLayout(self) + plotter = TransformPlot(rows=1) + plotter.setSizePolicy(QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)) + plot_layout = QtWidgets.QVBoxLayout() + plot_layout.addWidget(plotter.get_toolbar(self), alignment=Qt.AlignRight) + plot_layout.addWidget(plotter) + + transform_control = TransformWidget(dataset, plotter) + + layout.addWidget(transform_control, stretch=0, alignment=Qt.AlignLeft) + layout.addLayout(plot_layout, stretch=5) + + self.sigLoaded.emit(self) + + def get_state(self): + pass + + def restore_state(self, state): + pass + + +class DataSetTab(WorkspaceTab): + """Root workspace tab for DataSet controller manipulation""" + + def __init__(self, dataset: DataSetController, parent=None): + super().__init__(parent=parent, flags=Qt.Widget) + self.dataset = dataset + + self.ws_settings: dict = self.get_state() + + layout = QtWidgets.QVBoxLayout(self) + self.workspace = QtWidgets.QTabWidget(self) + self.workspace.setTabPosition(QtWidgets.QTabWidget.West) + layout.addWidget(self.workspace) + + self.segment_tab = SegmentSelectTab(dataset, parent=self) + self.segment_tab.sigLoaded.connect(self._tab_loaded) + self.transform_tab = DataTransformTab(dataset, parent=self) + self.transform_tab.sigLoaded.connect(self._tab_loaded) + + self.workspace.addTab(self.segment_tab, "Data") + self.workspace.addTab(self.transform_tab, "Transform") + self.workspace.setCurrentIndex(0) + + @property + def title(self): + return f'{self.dataset.get_attr("name")} ' \ + f'[{self.dataset.parent().get_attr("name")}]' + + @property + def uid(self): + return self.dataset.uid + + def _tab_loaded(self, tab: SubTab): + """Restore tab state after initial loading is complete""" + state = self.ws_settings.get(tab.__class__.__name__, {}) + tab.restore_state(state) + + def save_state(self, state=None): + """Save current sub-tabs state then accept close event.""" + state = {} + for i in range(self.workspace.count()): + tab: SubTab = self.workspace.widget(i) + state[tab.__class__.__name__] = tab.get_state() + + super().save_state(state=state) + diff --git a/dgp/gui/workspaces/flight.py b/dgp/gui/workspaces/flight.py new file mode 100644 index 0000000..7425bfb --- /dev/null +++ b/dgp/gui/workspaces/flight.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +from PyQt5 import QtWidgets +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QWidget + +from dgp.core.controllers.flight_controller import FlightController +from .base import WorkspaceTab + + +class FlightMapTab(QWidget): + def __init__(self, flight, parent=None): + super().__init__(parent=parent, flags=Qt.Widget) + self.flight = flight + + +class FlightTab(WorkspaceTab): + def __init__(self, flight: FlightController, parent=None): + super().__init__(parent=parent, flags=Qt.Widget) + self.flight = flight + layout = QtWidgets.QHBoxLayout(self) + self.workspace = QtWidgets.QTabWidget() + self.workspace.addTab(FlightMapTab(self.flight), "Flight Map") + + layout.addWidget(self.workspace) + + @property + def title(self): + return f'{self.flight.get_attr("name")}' + + @property + def uid(self): + return self.flight.uid diff --git a/dgp/gui/workspaces/project.py b/dgp/gui/workspaces/project.py new file mode 100644 index 0000000..6eaee10 --- /dev/null +++ b/dgp/gui/workspaces/project.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from PyQt5.QtCore import Qt + +from dgp.core.controllers.project_controllers import AirborneProjectController +from .base import WorkspaceTab + + +class ProjectTab(WorkspaceTab): + def __init__(self, project: AirborneProjectController, parent=None): + super().__init__(parent=parent, flags=Qt.Widget) + self.project = project + + @property + def title(self) -> str: + return f'{self.project.get_attr("name")}' + + @property + def uid(self): + return self.project.uid diff --git a/docs/source/core/models.rst b/docs/source/core/models.rst index 6d827d3..07684be 100644 --- a/docs/source/core/models.rst +++ b/docs/source/core/models.rst @@ -17,8 +17,8 @@ The following generally describes the class hierarchy of a typical Airborne proj | :obj:`~.project.AirborneProject` | ├── :obj:`~.flight.Flight` | │ ├── :obj:`~.dataset.DataSet` -| │ │ ├── :obj:`~.data.DataFile` -- Gravity -| │ │ ├── :obj:`~.data.DataFile` -- Trajectory +| │ │ ├── :obj:`~.datafile.DataFile` -- Gravity +| │ │ ├── :obj:`~.datafile.DataFile` -- Trajectory | │ │ └── :obj:`~.dataset.DataSegment` -- Container (Multiple) | │ └── :obj:`~.meter.Gravimeter` -- Link | └── :obj:`~.meter.Gravimeter` @@ -26,7 +26,7 @@ The following generally describes the class hierarchy of a typical Airborne proj ----------------------------------------- The project can have multiple :obj:`~.flight.Flight`, and each Flight can have -0 or more :obj:`~.flight.FlightLine`, :obj:`~.data.DataFile`, and linked +0 or more :obj:`~.flight.FlightLine`, :obj:`~.datafile.DataFile`, and linked :obj:`~.meter.Gravimeter`. The project can also define multiple Gravimeters, of varying type with specific configuration files assigned to each. @@ -100,10 +100,10 @@ dgp.core.models.flight module .. automodule:: dgp.core.models.flight :undoc-members: -dgp.core.models.data module ---------------------------- +dgp.core.models.datafile module +------------------------------- -.. automodule:: dgp.core.models.data +.. automodule:: dgp.core.models.datafile :members: :undoc-members: diff --git a/tests/conftest.py b/tests/conftest.py index a31cd5c..1e3c0f4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,8 +8,10 @@ import pandas as pd import pytest from PyQt5 import QtCore +from PyQt5.QtCore import QSettings from PyQt5.QtWidgets import QApplication +from dgp.gui.settings import set_settings from dgp.core import DataType from dgp.core.controllers.project_controllers import AirborneProjectController from dgp.core.hdf5_manager import HDF5_NAME @@ -37,6 +39,20 @@ """ +@pytest.fixture(scope='session', autouse=True) +def shim_settings(): + """Override DGP Application settings object so as not to pollute the regular + settings when testing. + + This fixture will be automatically called, and will delete the settings ini + file at the conclusion of the test session. + """ + settings = QSettings(QSettings.IniFormat, QSettings.UserScope, "DgS", "DGP") + set_settings(settings) + yield + os.unlink(settings.fileName()) + + def qt_msg_handler(type_, context, message: str): level = { QtCore.QtDebugMsg: "QtDebug", @@ -67,7 +83,7 @@ def excepthook(type_, value, traceback_): QtCore.qFatal('') -# sys.excepthook = excepthook +sys.excepthook = excepthook APP = QApplication([]) diff --git a/tests/test_gui_main.py b/tests/test_gui_main.py index efd634b..f5ba82b 100644 --- a/tests/test_gui_main.py +++ b/tests/test_gui_main.py @@ -2,13 +2,12 @@ # Test gui/main.py import logging -import time from pathlib import Path import pytest from PyQt5.QtCore import Qt from PyQt5.QtTest import QSignalSpy, QTest -from PyQt5.QtWidgets import QMainWindow, QFileDialog, QProgressDialog, QPushButton +from PyQt5.QtWidgets import QMainWindow, QProgressDialog, QPushButton from dgp.core.oid import OID from dgp.core.models.project import AirborneProject @@ -16,26 +15,23 @@ from dgp.core.controllers.flight_controller import FlightController from dgp.core.controllers.project_controllers import AirborneProjectController from dgp.gui.main import MainWindow -from dgp.gui.workspace import WorkspaceTab from dgp.gui.dialogs.create_project_dialog import CreateProjectDialog from dgp.gui.utils import ProgressEvent @pytest.fixture -def flt_ctrl(prj_ctrl: AirborneProjectController): - return prj_ctrl.get_child(prj_ctrl.datamodel.flights[0].uid) - - -@pytest.fixture -def window(prj_ctrl): - return MainWindow(prj_ctrl) +def window(project) -> MainWindow: + window = MainWindow() + window.add_project(project) + yield window + window.close() -def test_MainWindow_load(window): +def test_MainWindow_load(window, project): assert isinstance(window, QMainWindow) assert not window.isVisible() - window.load() + window.load(project) assert window.isVisible() assert not window.isWindowModified() @@ -43,33 +39,34 @@ def test_MainWindow_load(window): assert not window.isVisible() -def test_MainWindow_tab_open_requested(flt_ctrl: FlightController, - window: MainWindow): +def test_MainWindow_tab_open_requested(project, window): assert isinstance(window.model, ProjectTreeModel) tab_open_spy = QSignalSpy(window.model.tabOpenRequested) assert 0 == len(tab_open_spy) assert 0 == window.workspace.count() + flt_ctrl = window.model.active_project.get_child(project.flights[0].uid) + assert isinstance(flt_ctrl, FlightController) assert window.workspace.get_tab(flt_ctrl.uid) is None window.model.item_activated(flt_ctrl.index()) assert 1 == len(tab_open_spy) assert 1 == window.workspace.count() - assert isinstance(window.workspace.currentWidget(), WorkspaceTab) window.model.item_activated(flt_ctrl.index()) assert 2 == len(tab_open_spy) assert 1 == window.workspace.count() -def test_MainWindow_tab_close_requested(flt_ctrl: AirborneProjectController, - window: MainWindow): +def test_MainWindow_tab_close_requested(project, window): tab_close_spy = QSignalSpy(window.model.tabCloseRequested) assert 0 == len(tab_close_spy) assert 0 == window.workspace.count() + flt_ctrl = window.model.active_project.get_child(project.flights[0].uid) + window.model.item_activated(flt_ctrl.index()) assert 1 == window.workspace.count() @@ -140,22 +137,24 @@ def test_MainWindow_open_project_dialog(window: MainWindow, project_factory, tmp assert window.model.active_project.path != prj2_ctrl.path assert 1 == window.model.rowCount() - window.open_project_dialog(path=prj2.path) + window.open_project(path=prj2.path, prompt=False) assert 2 == window.model.rowCount() # Try to open an already open project - window.open_project_dialog(path=prj2.path) + window.open_project(path=prj2.path, prompt=False) assert 2 == window.model.rowCount() - window.open_project_dialog(path=tmpdir) + with pytest.raises(FileNotFoundError): + window.open_project(path=Path(tmpdir), prompt=False) assert 2 == window.model.rowCount() -def test_MainWindow_progress_event_handler(window: MainWindow, - flt_ctrl: FlightController): +def test_MainWindow_progress_event_handler(project, window): model: ProjectTreeModel = window.model progressEventRequested_spy = QSignalSpy(model.progressNotificationRequested) + flt_ctrl = window.model.active_project.get_child(project.flights[0].uid) + prog_event = ProgressEvent(flt_ctrl.uid, label="Loading Data Set") assert flt_ctrl.uid == prog_event.uid assert not prog_event.completed diff --git a/tests/test_gui_utils.py b/tests/test_gui_utils.py index bcdc502..cd3cedb 100644 --- a/tests/test_gui_utils.py +++ b/tests/test_gui_utils.py @@ -4,13 +4,3 @@ import dgp.gui.utils as utils -def test_get_project_file(tmpdir): - _dir = Path(tmpdir) - # _other_file = _dir.joinpath("abc.json") - # _other_file.touch() - _prj_file = _dir.joinpath("dgp.json") - _prj_file.touch() - - file = utils.get_project_file(_dir) - assert _prj_file.resolve() == file - diff --git a/tests/test_project_treemodel.py b/tests/test_project_treemodel.py index 7c5af0b..1d8e341 100644 --- a/tests/test_project_treemodel.py +++ b/tests/test_project_treemodel.py @@ -46,10 +46,4 @@ def test_ProjectTreeModel_item_activated(prj_ctrl: AirborneProjectController, parent=model.index(prj_ctrl.row(), 0))) assert not flt_ctrl.is_active model.item_activated(fc1_index) - assert flt_ctrl.is_active assert 1 == len(tabOpen_spy) - assert flt_ctrl is prj_ctrl.active_child - - _no_exist_uid = OID() - assert prj_ctrl.activate_child(_no_exist_uid) is None - diff --git a/tests/test_workspaces.py b/tests/test_workspaces.py index 14a6bc7..d7e238a 100644 --- a/tests/test_workspaces.py +++ b/tests/test_workspaces.py @@ -2,38 +2,4 @@ # Tests for gui workspace widgets in gui/workspaces -import pytest -import pandas as pd - -from dgp.core.controllers.dataset_controller import DataSetController -from dgp.core.models.project import AirborneProject -from dgp.core.controllers.project_controllers import AirborneProjectController -from dgp.gui.workspaces import PlotTab -from dgp.gui.workspaces.TransformTab import TransformWidget, _Mode - - -def test_plot_tab_init(project: AirborneProject): - prj_ctrl = AirborneProjectController(project) - flt1_ctrl = prj_ctrl.get_child(project.flights[0].uid) - ds_ctrl = flt1_ctrl.get_child(flt1_ctrl.datamodel.datasets[0].uid) - assert isinstance(ds_ctrl, DataSetController) - assert ds_ctrl == flt1_ctrl.active_child - assert pd.DataFrame().equals(ds_ctrl.dataframe()) - - ptab = PlotTab("TestTab", flt1_ctrl) - - -def test_TransformTab_modes(prj_ctrl, flt_ctrl, gravdata): - ttab = TransformWidget(flt_ctrl) - - assert ttab._mode is _Mode.NORMAL - ttab.qpb_toggle_mode.click() - assert ttab._mode is _Mode.SEGMENTS - ttab.qpb_toggle_mode.click() - assert ttab._mode is _Mode.NORMAL - - assert 0 == len(ttab._plot.get_plot(0).curves) - ttab._add_series(gravdata['gravity']) - assert 1 == len(ttab._plot.get_plot(0).curves) - ttab._remove_series(gravdata['gravity']) - assert 0 == len(ttab._plot.get_plot(0).curves) +# TODO: Reimplement these