diff --git a/.travis.yml b/.travis.yml index aaedbe2..e3c9a9c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,11 +13,13 @@ matrix: - env: - PYTHON_VERSION=3.8 - MPL_VERSION=3.1 + - QT_VERSION=5 - DEPLOY_CONDA=true os: linux - env: - PYTHON_VERSION=3.7 - MPL_VERSION=3.1 + - QT_VERSION=5 - DEPLOY_CONDA=true os: linux - env: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a5e6577..20823ed 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,12 @@ v1.2.1 ====== Added ----- +* The ``xgrid`` and ``ygrid`` formatoptions now have a new widget in the GUI + (see `#17 `__) +* The ``lsm`` formatoption now supports a multitude of different options. You + can specify a land color, and ocean color and the coast lines color. These + settings can now also be set through the psyplot GUI + (see `#17 `__). * a new ``background`` formatoption has been implemented that allows to set the facecolor of the axes (i.e. the background color for the plot) * compatibility for cartopy 0.18 (see `#14 `__) @@ -16,6 +22,9 @@ Added Changed ------- +* the ``lsm`` formatoptions value is now a dictionary. Old values, such as + the string ``'10m'`` or ``['10m', 1.0]`` are still valid and will be converted + to a dictionary (see `#17 `__). * the value ``None`` for the ``map_extent`` formatoption now triggers a call of the :meth:`~matplotlib.axes._base.AxesBase.autoscale` of the axes, see `#12 `__. Before, it was diff --git a/ci/conda-recipe/meta.yaml b/ci/conda-recipe/meta.yaml index c994b89..3555b62 100644 --- a/ci/conda-recipe/meta.yaml +++ b/ci/conda-recipe/meta.yaml @@ -40,8 +40,8 @@ test: source_files: - tests commands: - - pytest -sv --cov=psy_maps --ref - - py.test -sv --cov-append --cov=psy_maps + - pytest -sv --cov=psy_maps --ref --ignore=tests/widgets + - pytest -sv --cov-append --cov=psy_maps --ignore=tests/widgets about: home: https://github.com/psyplot/psy-maps diff --git a/ci/setup_append.py b/ci/setup_append.py index 7736ea6..e6f76fb 100644 --- a/ci/setup_append.py +++ b/ci/setup_append.py @@ -34,6 +34,7 @@ 0, "pytest --cov=psy_maps --cov-append -v tests/widgets") config["test"]["imports"] = ["psy_maps.widgets"] config["test"]["requires"].append("psyplot-gui") + config["test"]["requires"].append("pytest-qt") with open(output, 'w') as f: yaml.dump(config, f) diff --git a/psy_maps/plotters.py b/psy_maps/plotters.py index bc0c7f5..37b1f92 100755 --- a/psy_maps/plotters.py +++ b/psy_maps/plotters.py @@ -7,6 +7,7 @@ from itertools import starmap, chain, repeat import cartopy import cartopy.crs as ccrs +import cartopy.feature as cf from cartopy.mpl.gridliner import Gridliner import matplotlib as mpl import matplotlib.ticker as ticker @@ -1111,27 +1112,92 @@ class LSM(Formatoption): Possible types -------------- bool - True: draw the continents with a line width of 1 - False: don't draw the continents + True: draw the coastlines with a line width of 1 + False: don't draw anything float - Specifies the linewidth of the continents + Specifies the linewidth of the coastlines str The resolution of the land-sea mask (see the - :meth:`cartopy.mpl.geoaxes.GeoAxesSubplot.coastlines` method. Usually + :meth:`cartopy.mpl.geoaxes.GeoAxesSubplot.coastlines` method. Must be one of ``('110m', '50m', '10m')``. list [str or bool, float] - The resolution and the linewidth""" + The resolution and the linewidth + dict + A dictionary with any of the following keys + + coast + The color for the coastlines + land + The fill color for the continents + ocean + The fill color for the oceans + res + The resolution (see above) + linewidth + The linewidth of the coastlines (see above)""" name = 'Land-Sea mask' lsm = None + dependencies = ['background'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.draw_funcs = { + ('coast', ): self.draw_coast, + ('coast', 'land'): self.draw_land_coast, + ('land', ): self.draw_land, + ('ocean', ): self.draw_ocean, + ('coast', 'land', 'ocean'): self.draw_all, + ('land', 'ocean'): self.draw_land_ocean, + ('coast', 'ocean'): self.draw_ocean_coast, + } + + def draw_all(self, land, ocean, coast, res='110m', linewidth=1): + land_feature = cf.LAND.with_scale(res) + if land is None: + land = land_feature._kwargs.get('facecolor') + if ocean is None: + ocean = cf.OCEAN._kwargs.get('facecolor') + self.lsm = self.ax.add_feature( + land_feature, facecolor=land, edgecolor=coast, linewidth=linewidth) + self.ax.background_patch.set_facecolor(ocean) + + def draw_land(self, land, res='110m'): + self.draw_all(land, self.ax.background_patch.get_facecolor(), + 'face', res, 0.0) + + def draw_coast(self, coast, res='110m', linewidth=1.0): + if coast is None: + coast = 'k' + self.lsm = self.ax.coastlines(res, color=coast, linewidth=linewidth) + + def draw_ocean(self, ocean, res='110m'): + self.draw_ocean_coast(ocean, None, res, 0.0) + + def draw_land_coast(self, land, coast, res='110m', linewidth=1.0): + self.draw_all(land, self.ax.background_patch.get_facecolor(), coast, + res, linewidth) + + def draw_ocean_coast(self, ocean, coast, res='110m', linewidth=1.0): + ocean_feature = cf.OCEAN.with_scale(res) + if ocean is None: + ocean = cf.OCEAN._kwargs.get('facecolor') + self.lsm = self.ax.add_feature( + ocean_feature, facecolor=ocean, edgecolor=coast, + linewidth=linewidth) + + def draw_land_ocean(self, land, ocean, res='110m'): + self.draw_all(land, ocean, None, res, 0.0) + def update(self, value): self.remove() - res, lw = value - if res: - args = (res, ) if isinstance(res, six.string_types) else () - self.lsm = self.ax.coastlines(*args, linewidth=lw) + # to make sure, we have a dictionary + value = self.validate(value) + keys = tuple(sorted({'land', 'ocean', 'coast'}.intersection(value))) + if keys: + self.draw_funcs[keys](**value) def remove(self): if self.lsm is not None: @@ -1139,6 +1205,16 @@ def remove(self): self.lsm.remove() except ValueError: pass + finally: + del self.lsm + try: + self.background.update(self.background.value) + except Exception: + pass + + def get_fmt_widget(self, parent, project): + from psy_maps.widgets import LSMFmtWidget + return LSMFmtWidget(parent, self, project) class StockImage(Formatoption): @@ -1247,7 +1323,7 @@ def update(self, value): self.ax, self.ax.projection, draw_labels=test_value) except TypeError as e: # labels cannot be drawn if value: - warnings.warn(e.message, RuntimeWarning) + warnings.warn(str(e), RuntimeWarning) value = False else: value = True @@ -1403,6 +1479,10 @@ def _modify_gridliner(self, gridliner): gridliner.yformatter = lat_formatter gridliner.xformatter = lon_formatter + def get_fmt_widget(self, parent, project): + from psy_maps.widgets import GridFmtWidget + return GridFmtWidget(parent, self, project) + def remove(self): if not hasattr(self, '_gridliner'): return diff --git a/psy_maps/plugin.py b/psy_maps/plugin.py index ad1d081..f36cf7b 100644 --- a/psy_maps/plugin.py +++ b/psy_maps/plugin.py @@ -9,7 +9,7 @@ try_and_error, validate_none, validate_str, validate_float, validate_nseq_float, validate_bool_maybe_none, validate_fontsize, validate_color, validate_dict, BoundsValidator, bound_strings, - ValidateInStrings, validate_bool, BoundsType) + ValidateInStrings, validate_bool, BoundsType, DictValValidator) from psy_maps import __version__ as plugin_version @@ -54,21 +54,58 @@ def validate_grid(val): def validate_lsm(val): - res_validation = try_and_error(validate_bool, validate_str) - try: - val = res_validation(val) - except (ValueError, TypeError): - pass - else: - return [val, 1.0] - try: - val = validate_float(val) - except (ValueError, TypeError): - pass + res_validation = ValidateInStrings('lsm', ['110m', '50m' ,'10m']) + if not val: + val = {} + elif isinstance(val, dict): + invalid = set(val).difference( + ['coast', 'land', 'ocean', 'res', 'linewidth']) + if invalid: + raise ValueError(f"Invalid keys for lsm: {invalid}") else: - return [True, val] - res, lw = val - return [res_validation(res), validate_float(lw)] + # First try, if it's a bool, if yes, use 110m + # then try, if it's a valid resolution + # then try, if it's a float (i.e. the linewidth) + # then try if it's a tuple [res, lw] + try: + validate_bool(val) + except (ValueError, TypeError): + pass + else: + val = '110m' + try: + val = res_validation(val) + except (ValueError, TypeError): + pass + else: + if not isinstance(val, str): + val = '110m' + val = {'res': val, 'linewidth': 1.0, 'coast': 'k'} + try: + val = validate_float(val) + except (ValueError, TypeError): + pass + else: + val = {'res': '110m', 'linewidth': val, 'coast': 'k'} + if not isinstance(val, dict): + try: + res, lw = val + except (ValueError, TypeError): + raise ValueError(f"Invalid lsm configuration: {val}") + else: + val = {'res': res, 'linewidth': lw} + val = dict(val) + for key, v in val.items(): + if key in ['coast', 'land', 'ocean']: + val[key] = validate_color(v) + elif key == 'res': + val[key] = res_validation(v) + else: + val[key] = validate_float(v) # linewidth + # finally set black color if linewidth is in val + if 'linewidth' in val: + val.setdefault('coast', 'k') + return val class ProjectionValidator(ValidateInStrings): diff --git a/psy_maps/widgets/__init__.py b/psy_maps/widgets/__init__.py new file mode 100644 index 0000000..636f4f5 --- /dev/null +++ b/psy_maps/widgets/__init__.py @@ -0,0 +1,154 @@ +"""Formatoption widgets for psy-maps""" +import contextlib +from PyQt5 import QtWidgets, QtGui +import psy_simple.widgets.colors as psyps_wcol + + +class LSMFmtWidget(QtWidgets.QWidget): + """The widget for the land-sea-mask formatoption""" + + def __init__(self, parent, fmto, project): + super().__init__() + import cartopy.feature as cf + self.editor = parent + + self.cb_land = QtWidgets.QCheckBox('Land') + self.cb_ocean = QtWidgets.QCheckBox('Ocean') + self.cb_coast = QtWidgets.QCheckBox('Coastlines') + + self.land_color = psyps_wcol.ColorLabel(cf.LAND._kwargs['facecolor']) + self.ocean_color = psyps_wcol.ColorLabel(cf.OCEAN._kwargs['facecolor']) + self.coast_color = psyps_wcol.ColorLabel('k') + + self.txt_linewidth = QtWidgets.QLineEdit() + self.txt_linewidth.setValidator(QtGui.QDoubleValidator(0, 100, 4)) + self.txt_linewidth.setPlaceholderText('Linewidth of coastlines') + self.txt_linewidth.setToolTip('Linewidth of coastlines') + + self.combo_resolution = QtWidgets.QComboBox() + self.combo_resolution.addItems(['110m', '50m', '10m']) + + self.refresh(fmto.value) + + hbox = QtWidgets.QHBoxLayout() + hbox.addWidget(self.cb_land) + hbox.addWidget(self.land_color) + hbox.addWidget(self.cb_ocean) + hbox.addWidget(self.ocean_color) + hbox.addWidget(self.cb_coast) + hbox.addWidget(self.coast_color) + hbox.addWidget(self.txt_linewidth) + hbox.addWidget(self.combo_resolution) + self.setLayout(hbox) + + for cb in [self.cb_land, self.cb_ocean, self.cb_coast]: + cb.stateChanged.connect(self.toggle_and_update) + self.txt_linewidth.textEdited.connect(self.toggle_and_update) + self.combo_resolution.currentIndexChanged.connect( + self.toggle_and_update) + for lbl in [self.land_color, self.ocean_color, self.coast_color]: + lbl.color_changed.connect(self.toggle_and_update) + + @property + def value(self): + ret = {} + if self.cb_land.isChecked(): + ret['land'] = list(self.land_color.color.getRgbF()) + if self.cb_ocean.isChecked(): + ret['ocean'] = list(self.ocean_color.color.getRgbF()) + if self.cb_coast.isChecked(): + ret['coast'] = list(self.coast_color.color.getRgbF()) + ret['linewidth'] = float(self.txt_linewidth.text().strip() or 0.0) + if ret: + ret['res'] = self.combo_resolution.currentText() + return ret + + def toggle_and_update(self): + self.toggle_color_labels() + value = self.value + self.editor.set_obj(value) + + def toggle_color_labels(self): + self.land_color.setEnabled(self.cb_land.isChecked()) + self.ocean_color.setEnabled(self.cb_ocean.isChecked()) + + self.coast_color.setEnabled(self.cb_coast.isChecked()) + self.txt_linewidth.setEnabled(self.cb_coast.isChecked()) + + @contextlib.contextmanager + def block_widgets(self, *widgets): + widgets = widgets or [self.cb_land, self.cb_ocean, self.cb_coast, + self.land_color, self.ocean_color, + self.coast_color, + self.txt_linewidth, self.combo_resolution] + for w in widgets: + w.blockSignals(True) + yield + for w in widgets: + w.blockSignals(False) + + def refresh(self, value): + with self.block_widgets(): + self.cb_land.setChecked('land' in value) + self.cb_ocean.setChecked('ocean' in value) + self.cb_coast.setChecked('coast' in value) + + if 'linewidth' in value: + self.txt_linewidth.setText(str(value['linewidth'])) + elif 'coast' in value: + self.txt_linewidth.setText('1.0') + else: + self.txt_linewidth.setText('') + + if 'res' in value: + self.combo_resolution.setCurrentText(value['res']) + else: + self.combo_resolution.setCurrentText('110m') + + if 'land' in value: + self.land_color._set_color(value['land']) + if 'ocean' in value: + self.ocean_color._set_color(value['ocean']) + if 'coast' in value: + self.coast_color._set_color(value['coast']) + + self.toggle_color_labels() + + +class GridFmtWidget(psyps_wcol.CTicksFmtWidget): + """The formatoption widget for xgrid and ygrid""" + + methods = ['Discrete', 'Auto', 'Disable'] + + methods_type = psyps_wcol.BoundsType + + auto_val = True + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs, properties=False) + + def set_value(self, value): + if value is False or value is None: + with self.block_widgets(self.method_combo, self.type_combo): + self.type_combo.setCurrentText('Disable') + self.refresh_methods('Disable') + else: + super().set_value(value) + + def refresh_methods(self, text): + if text == 'Disable': + with self.block_widgets(self.method_combo): + self.method_combo.clear() + self.set_obj(False) + self.refresh_current_widget() + else: + super().refresh_methods(text) + + def refresh_current_widget(self): + w = self.current_widget + no_lines = self.type_combo.currentText() == 'Disable' + if no_lines and w is not None: + w.setVisible(False) + self.current_widget = None + if not no_lines: + super().refresh_current_widget() \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index fceebea..9649ffa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,8 @@ +try: + # make sure we import QtWebEngineWidgets at the start + import psyplot_gui.compat.qtcompat +except ImportError: + pass def pytest_addoption(parser): group = parser.getgroup("psyplot", "psyplot specific options") diff --git a/tests/test_base.py b/tests/test_base.py index 510bd51..e963aed 100755 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -150,7 +150,6 @@ def _label_test(self, key, label_func, has_time=False): key, label_func, has_time=has_time) -@pytest.mark.TWOD class BasePlotterTest2D(TestBase2D, BasePlotterTest): """Test :class:`psyplot.plotter.baseplotter.BasePlotter` class without time and vertical dimension""" diff --git a/tests/test_plotters.py b/tests/test_plotters.py index 30f0e16..81868f7 100755 --- a/tests/test_plotters.py +++ b/tests/test_plotters.py @@ -122,18 +122,21 @@ def ref_map_extent(self, close=True): if close: sp.close(True, True, True) - def ref_lsm(self, close=True): + def ref_lsm(self): """Create reference file for lsm formatoption. Create reference file for :attr:`~psyplot.plotter.maps.FieldPlotter.lsm` formatoption""" - sp = self.plot() - sp.update(lsm=False) - sp.export(os.path.join(bt.ref_dir, self.get_ref_file('lsm'))) - sp.update(lsm=['110m', 2.0]) - sp.export(os.path.join(bt.ref_dir, self.get_ref_file('lsm2'))) - if close: - sp.close(True, True, True) + for i, val in enumerate( + [False, ['110m', 2.0], {'land': '0.5'}, {'ocean': '0.5'}, + {'land': '0.5', 'ocean': '0.8'}, + {'land': '0.5', 'coast': 'r'}, + {'land': '0.5', 'linewidth': 5.0}, {'linewidth': 5.0}, + ], 1): + with self.plot(lsm=val) as sp: + sp.export(os.path.join( + bt.ref_dir, + self.get_ref_file('lsm{}'.format(i if i-1 else '')))) def ref_projection(self, close=True): """Create reference file for projection formatoption. @@ -357,6 +360,18 @@ def test_lsm(self, *args): self.compare_figures(next(iter(args), self.get_ref_file('lsm'))) self.update(lsm=['110m', 2.0]) self.compare_figures(next(iter(args), self.get_ref_file('lsm2'))) + self.update(lsm={'land': '0.5'}) + self.compare_figures(next(iter(args), self.get_ref_file('lsm3'))) + self.update(lsm={'ocean': '0.5'}) + self.compare_figures(next(iter(args), self.get_ref_file('lsm4'))) + self.update(lsm={'land': '0.5', 'ocean': '0.8'}) + self.compare_figures(next(iter(args), self.get_ref_file('lsm5'))) + self.update(lsm={'land': '0.5', 'coast': 'r'}) + self.compare_figures(next(iter(args), self.get_ref_file('lsm6'))) + self.update(lsm={'land': '0.5', 'linewidth': 5.0}) + self.compare_figures(next(iter(args), self.get_ref_file('lsm7'))) + self.update(lsm={'linewidth': 5.0}) + self.compare_figures(next(iter(args), self.get_ref_file('lsm8'))) def test_projection(self, *args): """Test projection formatoption""" diff --git a/tests/widgets/test_fmt_widgets.py b/tests/widgets/test_fmt_widgets.py new file mode 100644 index 0000000..ad624b4 --- /dev/null +++ b/tests/widgets/test_fmt_widgets.py @@ -0,0 +1,61 @@ +"""Test module for formatoption widgets""" +import os.path as osp +import pytest +from PyQt5.QtCore import Qt + + +@pytest.fixture +def test_ds(): + import psyplot.data as psyd + test_file = osp.join(osp.dirname(__file__), '..', 'test-t2m-u-v.nc') + with psyd.open_dataset(test_file) as ds: + yield ds + + +@pytest.fixture +def sp(test_ds): + with test_ds.psy.plot.mapplot(name='t2m') as sp: + yield sp + + +@pytest.fixture +def plotter(sp): + return sp.plotters[0] + + +@pytest.fixture +def mainwindow(qtbot): + from psyplot_gui.main import MainWindow, rcParams + with rcParams.catch(): + rcParams['console.start_channels'] = False + rcParams['main.listen_to_port'] = False + rcParams['help_explorer.render_docs_parallel'] = False + rcParams['help_explorer.use_intersphinx'] = False + window = MainWindow(show=False) + qtbot.addWidget(window) + yield window + + +def test_lsm_fmt_widget(mainwindow, plotter, qtbot): + from psy_maps.widgets import LSMFmtWidget + mainwindow.fmt_widget.fmto = plotter.lsm + + w = mainwindow.fmt_widget.fmt_widget + + assert isinstance(w, LSMFmtWidget) + + assert not 'land' in mainwindow.fmt_widget.get_obj() + assert not w.cb_land.isChecked() + + w.cb_land.setChecked(True) + + assert w.cb_land.isChecked() + assert 'land' in mainwindow.fmt_widget.get_obj() + + w.combo_resolution.setCurrentText('10m') + + assert mainwindow.fmt_widget.get_obj()['res'] == '10m' + + w.cb_coast.setChecked(False) + + assert 'linewidth' not in mainwindow.fmt_widget.get_obj()