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
-
+
+
@@ -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.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