• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Open-MSS / MSS / 20756662318

06 Jan 2026 05:37PM UTC coverage: 69.947% (+0.1%) from 69.817%
20756662318

Pull #2969

github

web-flow
Merge 7c4784cfd into 71950a3d9
Pull Request #2969: separated port reservation

14493 of 20720 relevant lines covered (69.95%)

2.1 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.29
/mslib/msui/multilayers.py
1
"""
2
    mslib.msui.multilayers
3
    ~~~~~~~~~~~~~~~~~~~
4

5
    This module contains classes for object oriented managing of WMS layers.
6
    Improves upon the old method of loading each layer on UI changes,
7
    the layers are all persistent and fully functional without requiring user input.
8

9
    This file is part of MSS.
10

11
    :copyright: Copyright 2021 May Bär
12
    :copyright: Copyright 2021-2025 by the MSS team, see AUTHORS.
13
    :license: APACHE-2.0, see LICENSE for details.
14

15
    Licensed under the Apache License, Version 2.0 (the "License");
16
    you may not use this file except in compliance with the License.
17
    You may obtain a copy of the License at
18

19
       http://www.apache.org/licenses/LICENSE-2.0
20

21
    Unless required by applicable law or agreed to in writing, software
22
    distributed under the License is distributed on an "AS IS" BASIS,
23
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
24
    See the License for the specific language governing permissions and
25
    limitations under the License.
26
"""
27
from PyQt5 import QtWidgets, QtCore, QtGui
3✔
28
import logging
3✔
29
import mslib.msui.wms_control
3✔
30
from mslib.msui.icons import icons
3✔
31
from mslib.msui.qt5 import ui_wms_multilayers as ui
3✔
32
from mslib.utils.config import save_settings_qsettings, load_settings_qsettings
3✔
33

34

35
class Multilayers(QtWidgets.QDialog, ui.Ui_MultilayersDialog):
3✔
36
    """
37
    Contains all layers of all loaded WMS and provides helpful methods to manage them inside a popup dialog
38
    """
39

40
    needs_repopulate = QtCore.pyqtSignal()
3✔
41
    styles_on_change = QtCore.pyqtSignal(str)
3✔
42

43
    def __init__(self, dock_widget):
3✔
44
        super().__init__(parent=dock_widget)
3✔
45
        self.setupUi(self)
3✔
46
        self.setWindowFlags(QtCore.Qt.Window)
3✔
47
        if isinstance(dock_widget, mslib.msui.wms_control.HSecWMSControlWidget):
3✔
48
            self.setWindowTitle(self.windowTitle() + " (Top View)")
3✔
49
        elif isinstance(dock_widget, mslib.msui.wms_control.VSecWMSControlWidget):
3✔
50
            self.setWindowTitle(self.windowTitle() + " (Side View)")
3✔
51
        elif isinstance(dock_widget, mslib.msui.wms_control.LSecWMSControlWidget):
3✔
52
            self.setWindowTitle(self.windowTitle() + " (Linear View)")
3✔
53
        self.dock_widget = dock_widget
3✔
54
        self.layers = {}
3✔
55
        self.layers_priority = []
3✔
56
        self.current_layer: Layer = None
3✔
57
        self.threads = 0
3✔
58
        self.height = None
3✔
59
        self.scale = self.logicalDpiX() / 96
3✔
60
        self.filter_favourite = False
3✔
61
        self.carry_parameters = {"level": None, "itime": None, "vtime": None}
3✔
62
        self.is_linear = isinstance(dock_widget, mslib.msui.wms_control.LSecWMSControlWidget)
3✔
63
        self.settings = load_settings_qsettings("multilayers",
3✔
64
                                                {"favourites": [], "saved_styles": {}, "saved_colors": {}})
65
        self.synced_reference = Layer(None, None, None, is_empty=True)
3✔
66
        self.skip_clicked_event = False
3✔
67
        self.listLayers.itemChanged.connect(self.multilayer_changed)
3✔
68
        self.listLayers.itemClicked.connect(self.check_icon_clicked)
3✔
69
        self.listLayers.itemClicked.connect(self.multilayer_clicked)
3✔
70
        self.listLayers.itemDoubleClicked.connect(self.multilayer_doubleclicked)
3✔
71
        self.listLayers.setVisible(True)
3✔
72

73
        self.leMultiFilter.setVisible(True)
3✔
74
        self.lFilter.setVisible(True)
3✔
75
        self.filterFavouriteAction = self.leMultiFilter.addAction(QtGui.QIcon(icons("64x64", "star_unfilled.png")),
3✔
76
                                                                  QtWidgets.QLineEdit.TrailingPosition)
77
        self.filterRemoveAction = self.leMultiFilter.addAction(QtGui.QIcon(icons("64x64", "remove.png")),
3✔
78
                                                               QtWidgets.QLineEdit.TrailingPosition)
79
        self.filterRemoveAction.setVisible(False)
3✔
80
        self.filterRemoveAction.setToolTip("Click to remove the filter")
3✔
81
        self.filterFavouriteAction.setToolTip("Show only favourite layers")
3✔
82
        self.filterRemoveAction.triggered.connect(lambda x: self.remove_filter_triggered())
3✔
83
        self.filterFavouriteAction.triggered.connect(lambda x: self.filter_favourite_toggled())
3✔
84
        self.cbMultilayering.stateChanged.connect(self.toggle_multilayering)
3✔
85
        self.leMultiFilter.textChanged.connect(self.filter_multilayers)
3✔
86

87
        self.listLayers.setColumnWidth(2, 50)
3✔
88
        self.listLayers.setColumnWidth(3, 50)
3✔
89
        self.listLayers.setColumnWidth(1, 200)
3✔
90
        self.listLayers.setColumnHidden(2, True)
3✔
91
        self.listLayers.setColumnHidden(3, not self.is_linear)
3✔
92
        self.listLayers.header().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
3✔
93

94
        self.delete_shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("Delete"), self)
3✔
95
        self.delete_shortcut.activated.connect(self.delete_server)
3✔
96
        self.delete_shortcut.setWhatsThis("Delete selected server")
3✔
97

98
    def delete_server(self, server=None):
3✔
99
        if not server:
3✔
100
            server = self.listLayers.currentItem()
×
101

102
        if server and not isinstance(server, Layer):
3✔
103
            current = self.get_current_layer()
3✔
104
            if current in self.layers[server.text(0)].values():
3✔
105
                self.current_layer = None
3✔
106
            for child_index in range(server.childCount()):
3✔
107
                widget = server.child(child_index)
3✔
108
                if widget in self.layers_priority:
3✔
109
                    self.layers_priority.remove(widget)
×
110

111
            index = self.listLayers.indexOfTopLevelItem(server)
3✔
112
            self.layers.pop(server.text(0))
3✔
113
            self.listLayers.takeTopLevelItem(index)
3✔
114
            self.update_priority_selection()
3✔
115
            self.needs_repopulate.emit()
3✔
116

117
    def remove_filter_triggered(self):
3✔
118
        self.leMultiFilter.setText("")
3✔
119
        if self.filter_favourite:
3✔
120
            self.filter_favourite_toggled()
3✔
121

122
    def filter_favourite_toggled(self):
3✔
123
        self.filter_favourite = not self.filter_favourite
3✔
124
        if self.filter_favourite:
3✔
125
            self.filterFavouriteAction.setIcon(QtGui.QIcon(icons("64x64", "star_filled.png")))
3✔
126
            self.filterFavouriteAction.setToolTip("Disable showing only favourite layers")
3✔
127
        else:
128
            self.filterFavouriteAction.setIcon(QtGui.QIcon(icons("64x64", "star_unfilled.png")))
3✔
129
            self.filterFavouriteAction.setToolTip("Show only favourite layers")
3✔
130
        self.filter_multilayers()
3✔
131

132
    def check_icon_clicked(self, item):
3✔
133
        """
134
        Checks if the mouse is pointing at an icon and handles the event accordingly
135
        """
136
        icon_width = self.height - 2
3✔
137

138
        # Clicked on layer, check favourite
139
        if isinstance(item, Layer):
3✔
140
            starts_at = 40 * self.scale
3✔
141
            icon_start = starts_at + 3
3✔
142
            if self.cbMultilayering.isChecked():
3✔
143
                checkbox_width = round(self.height * 0.75)
×
144
                icon_start += checkbox_width + 6
×
145
            position = self.listLayers.viewport().mapFromGlobal(QtGui.QCursor().pos())
3✔
146
            if icon_start <= position.x() <= icon_start + icon_width:
3✔
147
                self.skip_clicked_event = True
3✔
148
                self.threads += 1
3✔
149
                item.favourite_triggered()
3✔
150
                if self.filter_favourite:
3✔
151
                    self.filter_multilayers()
×
152
                self.threads -= 1
3✔
153

154
        # Clicked on server, check garbage bin
155
        elif isinstance(item, QtWidgets.QTreeWidgetItem):
3✔
156
            starts_at = 20 * self.scale
3✔
157
            icon_start = starts_at + 3
3✔
158
            position = self.listLayers.viewport().mapFromGlobal(QtGui.QCursor().pos())
3✔
159
            if icon_start <= position.x() <= icon_start + icon_width:
3✔
160
                self.threads += 1
3✔
161
                self.delete_server(item)
3✔
162
                self.threads -= 1
3✔
163

164
    def get_current_layer(self):
3✔
165
        """
166
        Return the current layer in the perspective of Multilayering or Singlelayering
167
        For Multilayering, it is the first priority syncable layer, or first priority layer if none are syncable
168
        For Singlelayering, it is the current selected layer
169
        """
170
        if self.cbMultilayering.isChecked():
3✔
171
            active_layers = self.get_active_layers()
3✔
172
            synced_layers = [layer for layer in active_layers if layer.is_synced]
3✔
173
            return synced_layers[0] if synced_layers else active_layers[0] if active_layers else None
3✔
174
        else:
175
            return self.current_layer
3✔
176

177
    def reload_sync(self):
3✔
178
        """
179
        Updates the self.synced_reference layer to contain the common options of all synced layers
180
        """
181
        sr = self.synced_reference
3✔
182
        sr.levels, sr.itimes, sr.vtimes, sr.allowed_crs = self.get_multilayer_common_options()
3✔
183

184
        if self.current_layer is not None:
3✔
185
            if not sr.level:
3✔
186
                sr.level = self.current_layer.level
3✔
187
            if not sr.itime:
3✔
188
                sr.itime = self.current_layer.itime
3✔
189
            if not sr.vtime:
3✔
190
                sr.vtime = self.current_layer.vtime
3✔
191

192
        if sr.level not in sr.levels:
3✔
193
            sr.level = None
×
194
            if sr.levels:
×
195
                sr.level = sr.levels[0]
×
196

197
        if sr.itime not in sr.itimes:
3✔
198
            sr.itime = None
×
199
            if sr.itimes:
×
200
                sr.itime = sr.itimes[-1]
×
201

202
        if sr.itime is not None:
3✔
203
            if sr.vtime not in sr.vtimes or sr.vtime < sr.itime:
3✔
204
                sr.vtime = None
×
205
                if sr.vtimes:
×
206
                    sr.vtime = next((vtime for vtime in sr.vtimes if vtime >= sr.itime), None)
×
207
        elif sr.vtime not in sr.vtimes:
×
208
            sr.vtime = None
×
209
            if sr.vtimes:
×
210
                sr.vtime = sr.vtimes[0]
×
211

212
    def filter_multilayers(self, filter_string=None):
3✔
213
        """
214
        Hides all multilayers that don't contain the filter_string
215
        Shows all multilayers that do
216
        """
217
        if filter_string is None:
3✔
218
            filter_string = self.leMultiFilter.text()
3✔
219

220
        for wms_name in self.layers:
3✔
221
            header = self.layers[wms_name]["header"]
3✔
222
            wms_hits = 0
3✔
223
            for child_index in range(header.childCount()):
3✔
224
                widget = header.child(child_index)
3✔
225
                if filter_string.lower() in widget.text(0).lower() and (
3✔
226
                        not self.filter_favourite or widget.is_favourite):
227
                    widget.setHidden(False)
3✔
228
                    wms_hits += 1
3✔
229
                else:
230
                    widget.setHidden(True)
3✔
231
            if wms_hits == 0 and (len(filter_string) > 0 or self.filter_favourite):
3✔
232
                header.setHidden(True)
3✔
233
            else:
234
                header.setHidden(False)
3✔
235

236
        self.filterRemoveAction.setVisible(self.filter_favourite or len(filter_string) > 0)
3✔
237

238
    def get_multilayer_common_options(self, additional_layer=None):
3✔
239
        """
240
        Return the common option for levels, init_times, valid_times and CRS
241
        for all synchronised layers and the additional provided one
242
        """
243
        layers = self.get_active_layers(only_synced=True)
3✔
244
        if additional_layer:
3✔
245
            layers.append(additional_layer)
3✔
246

247
        elevation_values = []
3✔
248
        init_time_values = []
3✔
249
        valid_time_values = []
3✔
250
        crs_values = []
3✔
251

252
        for layer in layers:
3✔
253
            if len(layer.levels) > 0:
3✔
254
                elevation_values.append(layer.levels)
3✔
255
            init_time_values.append(layer.itimes)
3✔
256
            valid_time_values.append(layer.vtimes)
3✔
257
            crs_values.append(layer.allowed_crs)
3✔
258

259
        for values in elevation_values:
3✔
260
            elevation_values[0] = list(set(elevation_values[0]).intersection(values))
3✔
261
        for values in init_time_values:
3✔
262
            init_time_values[0] = list(set(init_time_values[0]).intersection(values))
3✔
263
        for values in valid_time_values:
3✔
264
            valid_time_values[0] = list(set(valid_time_values[0]).intersection(values))
3✔
265
        for values in crs_values:
3✔
266
            crs_values[0] = list(set(crs_values[0]).intersection(values))
3✔
267

268
        return sorted(elevation_values[0], key=lambda x: float(x.split()[0])) if len(elevation_values) > 0 else [], \
3✔
269
            sorted(init_time_values[0]) if len(init_time_values) > 0 else [], \
270
            sorted(valid_time_values[0]) if len(valid_time_values) > 0 else [], \
271
            sorted(crs_values[0]) if len(crs_values) > 0 else []
272

273
    def get_multilayer_priority(self, layer_widget):
3✔
274
        """
275
        Returns the priority of a layer, with a default of 999 if it wasn't explicitly set
276
        """
277
        priority = self.listLayers.itemWidget(layer_widget, 2)
3✔
278
        return int(priority.currentText()) if priority else 999
3✔
279

280
    def get_active_layers(self, only_synced=False):
3✔
281
        """
282
        Returns a list of every layer that has been checked
283
        """
284
        active_layers = []
3✔
285
        for wms_name in self.layers:
3✔
286
            header = self.layers[wms_name]["header"]
3✔
287
            for child_index in range(header.childCount()):
3✔
288
                widget = header.child(child_index)
3✔
289
                if widget.checkState(0) > 0 if not only_synced else widget.is_synced:
3✔
290
                    active_layers.append(widget)
3✔
291
        return sorted(active_layers, key=lambda layer: self.get_multilayer_priority(layer))
3✔
292

293
    def get_plot_title(self):
3✔
294
        """
295
        Returns the plot title
296
        """
297
        title = self.get_current_layer().layerobj.title
3✔
298
        if len(self.get_active_layers()) > 1 and self.get_current_layer().checkState(0):
3✔
299
            title = f"{title} (and {len(self.get_active_layers()) - 1} more)"
3✔
300
        return title
3✔
301

302
    def update_priority_selection(self):
3✔
303
        """
304
        Updates the priority numbers for the selected layers to the sorted self.layers_priority list
305
        """
306
        active_layers = self.get_active_layers()
3✔
307
        possible_values = [str(x) for x in range(1, len(active_layers) + 1)]
3✔
308
        for layer in active_layers:
3✔
309
            priority = self.listLayers.itemWidget(layer, 2)
3✔
310
            if priority is not None:
3✔
311
                # Update available numbers
312
                priority.currentIndexChanged.disconnect(self.priority_changed)
3✔
313
                priority.clear()
3✔
314
                priority.addItems(possible_values)
3✔
315
                # Update selected number
316
                priority.setCurrentIndex(self.layers_priority.index(layer))
3✔
317
                priority.currentIndexChanged.connect(self.priority_changed)
3✔
318

319
    def add_wms(self, wms):
3✔
320
        """
321
        Adds a wms to the multilayer list
322
        """
323
        if wms.url not in self.layers:
3✔
324
            header = QtWidgets.QTreeWidgetItem(self.listLayers)
3✔
325
            header.setText(0, wms.url)
3✔
326
            header.wms_name = wms.url
3✔
327
            self.layers[wms.url] = {}
3✔
328
            self.layers[wms.url]["header"] = header
3✔
329
            self.layers[wms.url]["wms"] = wms
3✔
330
            header.setExpanded(True)
3✔
331
            if not self.height:
3✔
332
                self.height = self.listLayers.visualItemRect(header).height()
3✔
333
            icon = QtGui.QIcon(icons("64x64", "bin.png"))
3✔
334
            header.setIcon(0, icon)
3✔
335

336
    def add_multilayer(self, name, wms, auto_select=False):
3✔
337
        """
338
        Adds a layer to the multilayer list, with the wms url as a parent
339
        """
340
        if name not in self.layers[wms.url]:
3✔
341
            layerobj = self.dock_widget.get_layer_object(wms, name.split(" | ")[-1])
3✔
342
            widget = Layer(self.layers[wms.url]["header"], self, layerobj, name=name)
3✔
343

344
            widget.wms_name = wms.url
3✔
345
            if layerobj.abstract:
3✔
346
                widget.setToolTip(0, layerobj.abstract)
3✔
347
            if self.cbMultilayering.isChecked():
3✔
348
                widget.setCheckState(0, QtCore.Qt.Unchecked)
×
349

350
            if self.is_linear:
3✔
351
                color = QtWidgets.QPushButton()
3✔
352
                color.setFixedHeight(15)
3✔
353
                color.setStyleSheet(f"background-color: {widget.color}")
3✔
354
                self.listLayers.setItemWidget(widget, 3, color)
3✔
355

356
                def color_changed(layer):
3✔
357
                    self.multilayer_clicked(layer)
×
358
                    new_color = QtWidgets.QColorDialog.getColor().name()
×
359
                    color.setStyleSheet(f"background-color: {new_color}")
×
360
                    layer.color_changed(new_color)
×
361
                    self.multilayer_clicked(layer)
×
362
                    self.dock_widget.auto_update()
×
363

364
                color.clicked.connect(lambda: color_changed(widget))
3✔
365

366
            if widget.style:
3✔
367
                style = QtWidgets.QComboBox()
3✔
368
                style.setFixedHeight(self.height)
3✔
369
                style.setFixedWidth(200)
3✔
370
                style.addItems(widget.styles)
3✔
371
                style.setCurrentIndex(style.findText(widget.style))
3✔
372

373
                def style_changed(layer):
3✔
374
                    layer.style = self.listLayers.itemWidget(layer, 1).currentText()
×
375
                    layer.style_changed()
×
376
                    self.multilayer_clicked(layer)
×
377
                    self.dock_widget.auto_update()
×
378
                    self.styles_on_change.emit(layer.style)
×
379

380
                style.currentIndexChanged.connect(lambda: style_changed(widget))
3✔
381
                self.listLayers.setItemWidget(widget, 1, style)
3✔
382

383
            size = QtCore.QSize()
3✔
384
            size.setHeight(self.height)
3✔
385
            widget.setSizeHint(0, size)
3✔
386

387
            self.layers[wms.url][name] = widget
3✔
388
            if widget.is_invalid:
3✔
389
                widget.setDisabled(True)
3✔
390
                return
3✔
391

392
            if self.current_layer is None or auto_select:
3✔
393
                self.current_layer = widget
3✔
394
                self.listLayers.setCurrentItem(widget)
3✔
395

396
    def multilayer_clicked(self, item):
3✔
397
        """
398
        Gets called whenever the user clicks on a layer in the multilayer list
399
        Makes sure the dock widget updates its data depending on the users selection
400
        """
401
        if self.skip_clicked_event:
3✔
402
            self.skip_clicked_event = False
×
403
            return
×
404

405
        if not isinstance(item, Layer):
3✔
406
            index = self.cbWMS_URL.findText(item.text(0))
×
407
            if index != -1 and index != self.cbWMS_URL.currentIndex():
×
408
                self.cbWMS_URL.setCurrentIndex(index)
×
409
            return
×
410
        if item.is_invalid:
3✔
411
            return
×
412

413
        self.threads += 1
3✔
414

415
        if self.carry_parameters["level"] in item.get_levels():
3✔
416
            item.set_level(self.carry_parameters["level"])
×
417
        if self.carry_parameters["itime"] in item.get_itimes():
3✔
418
            item.set_itime(self.carry_parameters["itime"])
×
419
        if self.carry_parameters["vtime"] in item.get_vtimes():
3✔
420
            item.set_vtime(self.carry_parameters["vtime"])
×
421
        # check the first element of a fresh list can be clicked too
422
        if self.current_layer != item or list(self.layers[item.wms_name]).index(item.text(0)) == 2:
3✔
423
            self.current_layer = item
3✔
424
            self.listLayers.setCurrentItem(item)
3✔
425
            index = self.cbWMS_URL.findText(item.get_wms().url)
3✔
426
            if index != -1 and index != self.cbWMS_URL.currentIndex():
3✔
427
                self.cbWMS_URL.setCurrentIndex(index)
×
428
            self.needs_repopulate.emit()
3✔
429
            if not self.cbMultilayering.isChecked():
3✔
430
                QtCore.QTimer.singleShot(QtWidgets.QApplication.doubleClickInterval(), self.dock_widget.auto_update)
3✔
431
        self.threads -= 1
3✔
432

433
    def multilayer_doubleclicked(self, item, column):
3✔
434
        if isinstance(item, Layer):
×
435
            self.hide()
×
436

437
    def multilayer_changed(self, item):
3✔
438
        """
439
        Gets called whenever the checkmark for a layer is activate or deactivated
440
        Creates a priority combobox or removes it depending on the situation
441
        """
442
        if self.threads > 0:
3✔
443
            return
3✔
444

445
        if item.checkState(0) > 0 and not self.listLayers.itemWidget(item, 2):
3✔
446
            priority = QtWidgets.QComboBox()
3✔
447
            priority.setFixedHeight(self.height)
3✔
448
            priority.currentIndexChanged.connect(self.priority_changed)
3✔
449
            self.listLayers.setItemWidget(item, 2, priority)
3✔
450
            self.layers_priority.append(item)
3✔
451
            self.update_priority_selection()
3✔
452
            if (item.itimes or item.vtimes or item.levels) and self.is_sync_possible(item):
3✔
453
                item.is_synced = True
3✔
454
                self.reload_sync()
3✔
455
            elif not (item.itimes or item.vtimes or item.levels):
×
456
                item.is_active_unsynced = True
×
457
            self.update_checkboxes()
3✔
458
            self.needs_repopulate.emit()
3✔
459
            self.dock_widget.auto_update()
3✔
460
        elif item.checkState(0) == 0 and self.listLayers.itemWidget(item, 2):
3✔
461
            if item in self.layers_priority:
3✔
462
                self.listLayers.removeItemWidget(item, 2)
3✔
463
                self.layers_priority.remove(item)
3✔
464
                self.update_priority_selection()
3✔
465
            item.is_synced = False
3✔
466
            item.is_active_unsynced = False
3✔
467
            self.reload_sync()
3✔
468
            self.update_checkboxes()
3✔
469
            self.needs_repopulate.emit()
3✔
470
            self.dock_widget.auto_update()
3✔
471

472
    def priority_changed(self, new_index):
3✔
473
        """
474
        Get called whenever the user changes a priority for a layer
475
        Finds out the previous index and switches the layer position in self.layers_priority
476
        """
477
        active_layers = self.get_active_layers()
3✔
478
        old_index = [i for i in range(1, len(active_layers) + 1)]
3✔
479
        for layer in active_layers:
3✔
480
            value = self.get_multilayer_priority(layer)
3✔
481
            if value in old_index:
3✔
482
                old_index.remove(value)
3✔
483
        old_index = old_index[0] - 1
3✔
484

485
        to_move = self.layers_priority.pop(old_index)
3✔
486
        self.layers_priority.insert(new_index, to_move)
3✔
487
        self.update_priority_selection()
3✔
488
        self.multilayer_clicked(self.layers_priority[new_index])
3✔
489
        self.needs_repopulate.emit()
3✔
490
        self.dock_widget.auto_update()
3✔
491

492
    def update_checkboxes(self):
3✔
493
        """
494
        Activates or deactivates the checkboxes for every layer depending on whether they
495
        can be synchronised or not
496
        """
497
        self.threads += 1
3✔
498
        for wms_name in self.layers:
3✔
499
            header = self.layers[wms_name]["header"]
3✔
500
            for child_index in range(header.childCount()):
3✔
501
                layer = header.child(child_index)
3✔
502
                is_active = self.is_sync_possible(layer) or not (layer.itimes or layer.vtimes or layer.levels)
3✔
503
                layer.setDisabled(not is_active or layer.is_invalid)
3✔
504
        self.threads -= 1
3✔
505

506
    def is_sync_possible(self, layer):
3✔
507
        """
508
        Returns whether the passed layer can be synchronised with all other synchronised layers
509
        """
510
        if len(self.get_active_layers()) == 0:
3✔
511
            return True
3✔
512

513
        levels, itimes, vtimes, crs = self.get_multilayer_common_options(layer)
3✔
514
        levels_before, itimes_before, vtimes_before, crs_before = self.get_multilayer_common_options()
3✔
515

516
        return (len(levels) > 0 or (len(levels_before) == 0 and len(layer.levels) == 0)) and \
3✔
517
               (len(itimes) > 0 or (len(itimes_before) == 0 and len(layer.itimes) == 0)) and \
518
               (len(vtimes) > 0 or (len(vtimes_before) == 0 and len(layer.vtimes) == 0))
519

520
    def toggle_multilayering(self):
3✔
521
        """
522
        Toggle between checkable layers (multilayering) and single layer mode
523
        """
524
        self.threads += 1
3✔
525
        for wms_name in self.layers:
3✔
526
            header = self.layers[wms_name]["header"]
3✔
527
            for child_index in range(header.childCount()):
3✔
528
                layer = header.child(child_index)
3✔
529
                if self.cbMultilayering.isChecked():
3✔
530
                    layer.setCheckState(0, 2 if layer.is_synced or layer.is_active_unsynced else 0)
3✔
531
                else:
532
                    layer.setData(0, QtCore.Qt.CheckStateRole, QtCore.QVariant())
3✔
533
                    layer.setDisabled(layer.is_invalid)
3✔
534

535
        if self.cbMultilayering.isChecked():
3✔
536
            self.update_checkboxes()
3✔
537
            self.listLayers.setColumnHidden(2, False)
3✔
538
        else:
539
            self.listLayers.setColumnHidden(2, True)
3✔
540

541
        self.needs_repopulate.emit()
3✔
542
        self.threads -= 1
3✔
543

544

545
class Layer(QtWidgets.QTreeWidgetItem):
3✔
546
    def __init__(self, header, parent, layerobj, name=None, is_empty=False):
3✔
547
        super().__init__(header)
3✔
548
        self.parent = parent
3✔
549
        self.header = header
3✔
550
        self.layerobj = layerobj
3✔
551
        self.dimensions = {}
3✔
552
        self.extents = {}
3✔
553
        self.setText(0, name if name else "")
3✔
554

555
        self.levels = []
3✔
556
        self.level = None
3✔
557
        self.itimes = []
3✔
558
        self.itime = None
3✔
559
        self.itime_name = None
3✔
560
        self.allowed_init_times = []
3✔
561
        self.vtimes = []
3✔
562
        self.vtime = None
3✔
563
        self.vtime_name = None
3✔
564
        self.allowed_valid_times = []
3✔
565
        self.styles = []
3✔
566
        self.style = None
3✔
567
        self.is_synced = False
3✔
568
        self.is_active_unsynced = False
3✔
569
        self.is_favourite = False
3✔
570
        self.is_invalid = False
3✔
571

572
        if not is_empty:
3✔
573
            self._parse_layerobj()
3✔
574
            self._parse_levels()
3✔
575
            self._parse_itimes()
3✔
576
            self._parse_vtimes()
3✔
577
            self._parse_styles()
3✔
578
            self.is_favourite = str(self) in self.parent.settings["favourites"]
3✔
579
            self.show_favourite()
3✔
580
            if str(self) in self.parent.settings["saved_colors"]:
3✔
581
                self.color = self.parent.settings["saved_colors"][str(self)]
×
582
            else:
583
                self.color = "#00aaff"
3✔
584

585
    def _parse_layerobj(self):
3✔
586
        """
587
        Parses the dimensions and extents out of the self.layerobj
588
        """
589
        self.allowed_crs = []
3✔
590
        lobj = self.layerobj
3✔
591
        while lobj is not None:
3✔
592
            self.dimensions.update(lobj.dimensions)
3✔
593
            for key in lobj.extents:
3✔
594
                if key not in self.extents:
3✔
595
                    self.extents[key] = lobj.extents[key]
3✔
596
            if len(self.allowed_crs) == 0:
3✔
597
                self.allowed_crs = getattr(lobj, "crsOptions", None)
3✔
598
            lobj = lobj.parent
3✔
599

600
    def _parse_levels(self):
3✔
601
        """
602
        Extracts and saves the possible levels for the layer
603
        """
604
        if "elevation" in self.extents:
3✔
605
            units = self.dimensions["elevation"]["units"]
3✔
606
            values = self.extents["elevation"]["values"]
3✔
607
            self.levels = [f"{e.strip()} ({units})" for e in values]
3✔
608
            self.level = self.levels[0]
3✔
609

610
    def _parse_itimes(self):
3✔
611
        """
612
        Extracts and saves all init_time values for the layer
613
        """
614
        init_time_names = [x for x in ["init_time", "reference_time", "run"] if x in self.extents]
3✔
615

616
        # Both time dimension and time extent tags were found. Try to determine the
617
        # format of the date/time strings.
618
        if len(init_time_names) > 0:
3✔
619
            self.itime_name = init_time_names[0]
3✔
620
            values = self.extents[self.itime_name]["values"]
3✔
621
            self.allowed_init_times = sorted(self.parent.dock_widget.parse_time_extent(values))
3✔
622
            self.itimes = [_time.isoformat() + "Z" for _time in self.allowed_init_times]
3✔
623
            if len(self.allowed_init_times) == 0:
3✔
624
                logging.error("Cannot determine init time format of %s for %s", self.header.text(0), self.text(0))
×
625
                self.is_invalid = True
×
626
            else:
627
                self.itime = self.itimes[-1]
3✔
628

629
    def _parse_vtimes(self):
3✔
630
        """
631
        Extracts and saves all valid_time values for the layer
632
        """
633
        valid_time_names = [x for x in ["time", "forecast"] if x in self.extents]
3✔
634

635
        # Both time dimension and time extent tags were found. Try to determine the
636
        # format of the date/time strings.
637
        if len(valid_time_names) > 0:
3✔
638
            self.vtime_name = valid_time_names[0]
3✔
639
            values = self.extents[self.vtime_name]["values"]
3✔
640
            self.allowed_valid_times = sorted(self.parent.dock_widget.parse_time_extent(values))
3✔
641
            while len(self.allowed_valid_times) > 1000:
3✔
642
                logging.warning("Too many valid times (%s). discarding 90%%.", len(self.allowed_valid_times))
×
643
                self.allowed_valid_times = self.allowed_valid_times[::10]
×
644
            self.vtimes = [_time.isoformat() + "Z" for _time in self.allowed_valid_times]
3✔
645
            if len(self.allowed_valid_times) == 0:
3✔
646
                logging.error("Cannot determine valid time format of %s for %s", self.header.text(0), self.text(0))
3✔
647
                self.is_invalid = True
3✔
648
            else:
649
                if self.itime:
3✔
650
                    self.vtime = next((vtime for vtime in self.vtimes if vtime >= self.itime), self.vtimes[0])
3✔
651
                else:
652
                    self.vtime = self.vtimes[0]
3✔
653

654
    def _parse_styles(self):
3✔
655
        """
656
        Extracts and saves all styles for the layer.
657
        Sets the layers style to the first one, or the saved one if possible.
658
        """
659
        self.styles = [f"{style} | {self.layerobj.styles[style]['title']}" for style in self.layerobj.styles]
3✔
660
        if self.parent.is_linear:
3✔
661
            self.styles.extend(["linear | linear scaled y-axis", "log | log scaled y-axis"])
3✔
662
        if len(self.styles) > 0:
3✔
663
            self.style = self.styles[0]
3✔
664
            if str(self) in self.parent.settings["saved_styles"] and \
3✔
665
               self.parent.settings["saved_styles"][str(self)] in self.styles:
666
                self.style = self.parent.settings["saved_styles"][str(self)]
×
667

668
    def get_level(self):
3✔
669
        if not self.parent.cbMultilayering.isChecked() or not self.is_synced:
3✔
670
            return self.level
3✔
671
        else:
672
            return self.parent.synced_reference.level
3✔
673

674
    def get_levels(self):
3✔
675
        if not self.parent.cbMultilayering.isChecked() or not self.is_synced:
3✔
676
            return self.levels
3✔
677
        else:
678
            return self.parent.synced_reference.levels
3✔
679

680
    def get_itimes(self):
3✔
681
        if not self.parent.cbMultilayering.isChecked() or not self.is_synced:
3✔
682
            return self.itimes
3✔
683
        else:
684
            return self.parent.synced_reference.itimes
3✔
685

686
    def get_itime(self):
3✔
687
        if not self.parent.cbMultilayering.isChecked() or not self.is_synced:
3✔
688
            return self.itime
3✔
689
        else:
690
            return self.parent.synced_reference.itime
3✔
691

692
    def get_vtimes(self):
3✔
693
        if not self.parent.cbMultilayering.isChecked() or not self.is_synced:
3✔
694
            return self.vtimes
3✔
695
        else:
696
            return self.parent.synced_reference.vtimes
3✔
697

698
    def get_vtime(self):
3✔
699
        if not self.parent.cbMultilayering.isChecked() or not self.is_synced:
3✔
700
            return self.vtime
3✔
701
        else:
702
            return self.parent.synced_reference.vtime
3✔
703

704
    def set_level(self, level):
3✔
705
        if (not self.parent.cbMultilayering.isChecked() or not self.is_synced) and level in self.levels:
3✔
706
            self.level = level
×
707
        elif self.is_synced and level in self.parent.synced_reference.levels:
3✔
708
            self.parent.synced_reference.level = level
3✔
709

710
    def set_itime(self, itime):
3✔
711
        if (not self.parent.cbMultilayering.isChecked() or not self.is_synced) and itime in self.itimes:
3✔
712
            self.itime = itime
3✔
713
        elif self.is_synced and itime in self.parent.synced_reference.itimes:
×
714
            self.parent.synced_reference.itime = itime
×
715

716
        if self.get_vtime():
3✔
717
            if self.get_vtime() < itime:
3✔
718
                valid_vtime = next((vtime for vtime in self.get_vtimes() if vtime >= itime), None)
×
719
                if valid_vtime:
×
720
                    self.set_vtime(valid_vtime)
×
721
                    self.parent.carry_parameters["vtime"] = self.get_vtime()
×
722
            self.parent.needs_repopulate.emit()
3✔
723

724
    def set_vtime(self, vtime):
3✔
725
        if (not self.parent.cbMultilayering.isChecked() or not self.is_synced) and vtime in self.vtimes:
3✔
726
            self.vtime = vtime
×
727
        elif self.is_synced and vtime in self.parent.synced_reference.vtimes:
3✔
728
            self.parent.synced_reference.vtime = vtime
3✔
729

730
        if self.get_itime() and self.get_itime() > vtime:
3✔
731
            valid_itimes = [itime for itime in self.get_itimes() if itime <= vtime]
×
732
            if valid_itimes:
×
733
                self.set_itime(valid_itimes[-1])
×
734
                self.parent.needs_repopulate.emit()
×
735

736
    def get_layer(self):
3✔
737
        """
738
        Returns the layer name used internally by the WMS
739
        """
740
        return self.text(0).split(" | ")[-1].split(" (synced)")[0]
3✔
741

742
    def get_style(self):
3✔
743
        """
744
        Returns the style name used internally by the WMS
745
        """
746
        if self.style:
3✔
747
            return self.style.split(" |")[0]
3✔
748
        return ""
3✔
749

750
    def get_level_name(self):
3✔
751
        """
752
        Returns the level used internally by the WMS
753
        """
754
        if self.level:
3✔
755
            return self.get_level().split(" (")[0]
3✔
756

757
    def get_legend_url(self):
3✔
758
        if not self.parent.is_linear:
3✔
759
            style = self.get_style()
3✔
760
            urlstr = None
3✔
761
            if style and "legend" in self.layerobj.styles[style]:
3✔
762
                urlstr = self.layerobj.styles[style]["legend"]
×
763
            return urlstr
3✔
764

765
    def get_allowed_crs(self):
3✔
766
        if self.is_synced:
3✔
767
            return self.parent.synced_reference.allowed_crs
3✔
768
        else:
769
            return self.allowed_crs
3✔
770

771
    def draw(self):
3✔
772
        """
773
        Triggers the layer to be drawn by the WMSControlWidget
774
        """
775
        if isinstance(self.parent.dock_widget, mslib.msui.wms_control.HSecWMSControlWidget):
3✔
776
            self.parent.dock_widget.get_map([self])
×
777
        elif isinstance(self.parent.dock_widget, mslib.msui.wms_control.VSecWMSControlWidget):
3✔
778
            self.parent.dock_widget.get_vsec([self])
3✔
779
        else:
780
            self.parent.dock_widget.get_lsec([self])
×
781

782
    def get_wms(self):
3✔
783
        return self.parent.layers[self.header.text(0)]["wms"]
3✔
784

785
    def show_favourite(self):
3✔
786
        """
787
        Shows a filled star icon if this layer is a favourite layer or an unfilled one if not
788
        """
789
        if self.is_favourite:
3✔
790
            icon = QtGui.QIcon(icons("64x64", "star_filled.png"))
3✔
791
        else:
792
            icon = QtGui.QIcon(icons("64x64", "star_unfilled.png"))
3✔
793
        self.setIcon(0, icon)
3✔
794

795
    def style_changed(self):
3✔
796
        """
797
        Persistently saves the currently selected style of the layer, if it is not the first one
798
        """
799
        if not self.styles:
×
800
            self.style = None
×
801
            return
×
802

803
        if self.style != self.styles[0]:
×
804
            self.parent.settings["saved_styles"][str(self)] = self.style
×
805
        else:
806
            self.parent.settings["saved_styles"].pop(str(self), None)
×
807
        save_settings_qsettings("multilayers", self.parent.settings)
×
808

809
    def color_changed(self, color):
3✔
810
        """
811
        Persistently saves the currently selected color of the layer, if it isn't black
812
        """
813
        self.color = color
×
814
        if self.color != 0:
×
815
            self.parent.settings["saved_colors"][str(self)] = self.color
×
816
        else:
817
            self.parent.settings["saved_colors"].pop(str(self))
×
818
        save_settings_qsettings("multilayers", self.parent.settings)
×
819

820
    def favourite_triggered(self):
3✔
821
        """
822
        Toggles whether a layer is or is not a favourite
823
        """
824
        self.is_favourite = not self.is_favourite
3✔
825
        self.show_favourite()
3✔
826
        if not self.is_favourite and str(self) in self.parent.settings["favourites"]:
3✔
827
            self.parent.settings["favourites"].remove(str(self))
3✔
828
        elif self.is_favourite and str(self) not in self.parent.settings["favourites"]:
3✔
829
            self.parent.settings["favourites"].append(str(self))
3✔
830
        save_settings_qsettings("multilayers", self.parent.settings)
3✔
831

832
    def __str__(self):
3✔
833
        return f"{self.header.text(0) if self.header else ''}: {self.text(0)}"
3✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc