diff --git a/docs/usage.rst b/docs/usage.rst index aced779d0..7e9daca4d 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -124,6 +124,13 @@ the same scheme, host and port as the address of the capabilities. Otherwise the the login, and msui warns once per session. It also warns when the capabilities were loaded with https and the maps are to be requested with http. +WMS server list +~~~~~~~~~~~~~~~ +The list of WMS URLs of a view starts with the default servers of that view (``default_WMS``, +``default_VSEC_WMS``, ``default_LSEC_WMS``), followed by every URL msui loaded capabilities from, most +recent first. The recently used URLs are shared by all views. To forget one, open the list, highlight the URL +and press Del or Backspace; default servers are removed only by changing the configuration. + MSUI Flight track import/export plugins ....................................... diff --git a/mslib/msui/qt5/ui_wms_multilayers.py b/mslib/msui/qt5/ui_wms_multilayers.py index c303c19da..50951f9eb 100644 --- a/mslib/msui/qt5/ui_wms_multilayers.py +++ b/mslib/msui/qt5/ui_wms_multilayers.py @@ -2,9 +2,10 @@ # Form implementation generated from reading ui file 'mslib/msui/ui/ui_wms_multilayers.ui' # -# Created by: PyQt5 UI code generator 5.12.3 +# Created by: PyQt5 UI code generator 5.15.11 # -# WARNING! All changes made in this file will be lost! +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. from PyQt5 import QtCore, QtGui, QtWidgets @@ -97,7 +98,7 @@ def setupUi(self, MultilayersDialog): MultilayersDialog.addAction(self.actionCloseWindow) self.retranslateUi(MultilayersDialog) - self.actionCloseWindow.triggered.connect(MultilayersDialog.reject) + self.actionCloseWindow.triggered.connect(MultilayersDialog.reject) # type: ignore QtCore.QMetaObject.connectSlotsByName(MultilayersDialog) def retranslateUi(self, MultilayersDialog): @@ -109,7 +110,8 @@ def retranslateUi(self, MultilayersDialog): self.listLayers.headerItem().setText(2, _translate("MultilayersDialog", "Priority")) self.listLayers.headerItem().setText(3, _translate("MultilayersDialog", "Colour")) self.label_7.setText(_translate("MultilayersDialog", "WMS URL:")) - self.cbWMS_URL.setToolTip(_translate("MultilayersDialog", "Enter a valid WMS URL here.")) + self.cbWMS_URL.setToolTip(_translate("MultilayersDialog", "Enter a valid WMS URL here.\n" +"In the opened list, Del or Backspace forgets the highlighted recently used URL.")) self.cbWMS_URL.setItemText(0, _translate("MultilayersDialog", "http://localhost:8002/fc_wms")) self.cbWMS_URL.setItemText(1, _translate("MultilayersDialog", "http://osm.omniscale.net/proxy/service")) self.btGetCapabilities.setToolTip(_translate("MultilayersDialog", "Request the capabilities from the WMS server.")) diff --git a/mslib/msui/ui/ui_wms_multilayers.ui b/mslib/msui/ui/ui_wms_multilayers.ui index 46e7af928..6606df636 100644 --- a/mslib/msui/ui/ui_wms_multilayers.ui +++ b/mslib/msui/ui/ui_wms_multilayers.ui @@ -150,7 +150,8 @@ - Enter a valid WMS URL here. + Enter a valid WMS URL here. +In the opened list, Del or Backspace forgets the highlighted recently used URL. QComboBox QAbstractItemView { min-width: 800px; } diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index 61a2c7802..dfc9ef9d7 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -60,9 +60,30 @@ def add_wms_urls(combo_box, url_list): - combo_box_urls = [combo_box.itemText(_i) for _i in range(combo_box.count())] - for url in (_url for _url in url_list if _url not in combo_box_urls): - combo_box.addItem(url) + for url in url_list: + if combo_box.findText(url) == -1: + combo_box.addItem(url) + + +def load_recent_wms_urls(): + """Returns the WMS URLs msui connected to successfully, most recent first.""" + wms_settings = load_settings_qsettings('wms', {'recent_wms_urls': [], 'recent_wms_url': None}) + urls = list(wms_settings['recent_wms_urls'] or []) + # settings of msui versions that remembered only one URL + if wms_settings['recent_wms_url'] and wms_settings['recent_wms_url'] not in urls: + urls.append(wms_settings['recent_wms_url']) + return urls + + +def remember_wms_url(url): + """Puts url at the front of the recently used WMS URLs and stores them.""" + urls = [url] + [_url for _url in load_recent_wms_urls() if _url != url] + save_settings_qsettings('wms', {'recent_wms_urls': urls}) + + +def forget_wms_url(url): + """Removes url from the recently used WMS URLs.""" + save_settings_qsettings('wms', {'recent_wms_urls': [_url for _url in load_recent_wms_urls() if _url != url]}) class MSUIWebMapServiceMixin: @@ -547,12 +568,12 @@ def __init__(self, parent=None, default_WMS=None, wms_cache=None, view=None): # Initial list of WMS servers. self.multilayers.cbWMS_URL.setModel(QtGui.QStandardItemModel()) - if default_WMS is not None: - add_wms_urls(self.multilayers.cbWMS_URL, default_WMS) - # set last connected url to editable - wms_settings = load_settings_qsettings('wms', {'recent_wms_url': None}) - if wms_settings['recent_wms_url'] is not None: - add_wms_urls(self.multilayers.cbWMS_URL, [wms_settings['recent_wms_url']]) + self.default_wms_urls = list(default_WMS or []) + add_wms_urls(self.multilayers.cbWMS_URL, self.default_wms_urls) + # WMS servers connected to recently + add_wms_urls(self.multilayers.cbWMS_URL, load_recent_wms_urls()) + self.url_list_view = self.multilayers.cbWMS_URL.view() + self.url_list_view.installEventFilter(self) self.layerChangeInProgress = False self.save_level = None @@ -899,23 +920,8 @@ def on_success(wms): self.cpdlg.setValue(9) if wms is not None: - # update the combo box, if entry requires change/insertion - found = False - current_url = self.multilayers.cbWMS_URL.currentText() - for count in range(self.multilayers.cbWMS_URL.count()): - if self.multilayers.cbWMS_URL.itemText(count) == current_url: - self.multilayers.cbWMS_URL.setItemText(count, base_url) - self.multilayers.cbWMS_URL.setCurrentIndex(count) - found = True - break - if self.multilayers.cbWMS_URL.itemText(count) == base_url: - self.multilayers.cbWMS_URL.setCurrentIndex(count) - found = True - if not found: - logging.debug("inserting URL: %s", base_url) - add_wms_urls(self.multilayers.cbWMS_URL, [base_url]) - self.multilayers.cbWMS_URL.setEditText(base_url) - save_settings_qsettings('wms', {'recent_wms_url': base_url}) + self.select_wms_url(base_url) + remember_wms_url(base_url) self.activate_wms(wms, level=level) self.service_manager.cache_service(base_url, wms) @@ -1011,6 +1017,52 @@ def confirm_redirect(self, entered_url, url): QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) return ret == QtWidgets.QMessageBox.Yes + def select_wms_url(self, base_url): + """ + Selects base_url, the URL the capabilities were loaded from, in the URL combo box. + + The URL as entered (e.g. without trailing slash, or before a redirect) is replaced by base_url, + or removed if base_url is listed already. + """ + combo = self.multilayers.cbWMS_URL + entered_index = combo.findText(combo.currentText()) + if combo.findText(base_url) == -1: + if entered_index != -1: + combo.setItemText(entered_index, base_url) + else: + logging.debug("inserting URL: %s", base_url) + combo.addItem(base_url) + elif entered_index not in (-1, combo.findText(base_url)): + with QtCore.QSignalBlocker(combo): + combo.removeItem(entered_index) + combo.setCurrentIndex(combo.findText(base_url)) + + def eventFilter(self, obj, event): + """Del or Backspace in the opened list of WMS URLs forgets the highlighted recently used URL.""" + if obj is self.url_list_view and event.type() == QtCore.QEvent.KeyPress \ + and event.key() in (QtCore.Qt.Key_Delete, QtCore.Qt.Key_Backspace): + index = self.url_list_view.currentIndex() + if index.isValid() and index.data() not in self.default_wms_urls: + self.forget_wms_server(index.data()) + return True + return super().eventFilter(obj, event) + + def forget_wms_server(self, url): + """ + Removes url from the recently used URLs and from the URL combo box. The entry stays in the + combo box while the server is loaded, as selecting its layers selects it there. + """ + forget_wms_url(url) + if url in self.multilayers.layers: + return + combo = self.multilayers.cbWMS_URL + text = combo.currentText() + # keep the entered text and do not load another server by changing it + with QtCore.QSignalBlocker(combo): + while (index := combo.findText(url)) != -1: + combo.removeItem(index) + combo.setEditText(text) + def warn_about_getmap_url(self, wms): """ Shows the warning of getmap_url_warning, once per session for each WMS and GetMap URL diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index 8d7350ec2..828b9f8a0 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -35,7 +35,7 @@ from xml.sax.saxutils import quoteattr from PyQt5 import QtCore, QtTest, QtWidgets from mslib.msui import flighttrack as ft -from mslib.msui.utils.config import config_loader +from mslib.msui.utils.config import config_loader, load_settings_qsettings, save_settings_qsettings import mslib.msui.wms_control as wc from tests.utils import shown_text from mslib.msui.utils.service_manager import strip_request_params @@ -55,6 +55,49 @@ class VSecViewMockup(mock.Mock): get_plot_size_in_px = mock.Mock(return_value=(200, 100)) +def test_recent_wms_urls(): + # the single URL remembered by older msui versions + save_settings_qsettings("wms", {"recent_wms_url": "http://old/"}) + assert wc.load_recent_wms_urls() == ["http://old/"] + for url in ["http://a/", "http://b/", "http://a/"]: + wc.remember_wms_url(url) + assert wc.load_recent_wms_urls() == ["http://a/", "http://b/", "http://old/"] + wc.forget_wms_url("http://b/") + assert wc.load_recent_wms_urls() == ["http://a/", "http://old/"] + save_settings_qsettings("wms", {"recent_wms_urls": ["http://single/"]}) + assert wc.load_recent_wms_urls() == ["http://single/"] + + +def test_add_wms_urls_skips_duplicates(qtbot): + combo = QtWidgets.QComboBox() + qtbot.addWidget(combo) + combo.addItem("http://a/") + wc.add_wms_urls(combo, ["http://a/", "http://b/", "http://b/"]) + assert [combo.itemText(i) for i in range(combo.count())] == ["http://a/", "http://b/"] + + +def press_in_url_list(combo, url, key=QtCore.Qt.Key_Delete): + """Opens the list of the URL combo box, highlights url and presses key.""" + combo.showPopup() + combo.view().setCurrentIndex(combo.model().index(combo.findText(url), 0)) + QtTest.QTest.keyClick(combo.view(), key) + combo.hidePopup() + + +def test_delete_in_url_list_keeps_default_url(qtbot, tmp_path): + wc.remember_wms_url("http://recent/") + window = wc.HSecWMSControlWidget(default_WMS=["http://default/"], view=HSecViewMockup(), wms_cache=tmp_path) + qtbot.addWidget(window) + combo = window.multilayers.cbWMS_URL + combo.setEditText("http://typed/") + press_in_url_list(combo, "http://default/") + assert combo.findText("http://default/") != -1 + press_in_url_list(combo, "http://recent/") + assert combo.findText("http://recent/") == -1 + assert wc.load_recent_wms_urls() == [] + assert combo.currentText() == "http://typed/" + + class WMSControlWidgetSetup: @pytest.fixture(autouse=True) def _with_mswms_server(self, mswms_server): @@ -448,6 +491,52 @@ def test_server_no_thread(self, mockthread, qtbot): assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 + def query_server_by_enter(self, qtbot, url): + """Types url and presses Enter, which inserts it into the combo box, before loading the capabilities.""" + self.window.multilayers.cbWMS_URL.setEditText("") + QtTest.QTest.keyClicks(self.window.multilayers.cbWMS_URL, url) + QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Return) + with qtbot.wait_signal(self.window.cpdlg.canceled, timeout=WMS_REQUEST_TIMEOUT_MS): + QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) + + def test_typed_url_is_remembered(self, qtbot): + self.query_server_by_enter(qtbot, self.url) + assert load_settings_qsettings("wms")["recent_wms_urls"][0] == f"{self.url}/" + other = wc.HSecWMSControlWidget(view=HSecViewMockup(), wms_cache=self.tempdir) + qtbot.addWidget(other) + assert other.multilayers.cbWMS_URL.findText(f"{self.url}/") != -1 + + def test_url_entered_again_is_listed_once(self, qtbot): + # entered without the trailing slash, loaded from the URL with it + self.query_server_by_enter(qtbot, self.url) + self.query_server_by_enter(qtbot, self.url) + combo = self.window.multilayers.cbWMS_URL + urls = [combo.itemText(i) for i in range(combo.count())] + assert urls.count(f"{self.url}/") == 1 + assert self.url not in urls + assert combo.currentText() == f"{self.url}/" + + @pytest.mark.parametrize("key", [QtCore.Qt.Key_Delete, QtCore.Qt.Key_Backspace], ids=["delete", "backspace"]) + def test_delete_in_url_list_forgets_url(self, qtbot, key): + self.query_server_by_enter(qtbot, self.url) + url = f"{self.url}/" + # unloading the server keeps its URL + server = self.window.multilayers.listLayers.findItems(url, QtCore.Qt.MatchFixedString)[0] + self.window.multilayers.delete_server(server) + assert url in wc.load_recent_wms_urls() + combo = self.window.multilayers.cbWMS_URL + press_in_url_list(combo, url, key) + assert url not in wc.load_recent_wms_urls() + assert combo.findText(url) == -1 + + def test_delete_in_url_list_keeps_loaded_server_listed(self, qtbot): + self.query_server_by_enter(qtbot, self.url) + url = f"{self.url}/" + combo = self.window.multilayers.cbWMS_URL + press_in_url_list(combo, url) + assert url not in wc.load_recent_wms_urls() + assert combo.findText(url) != -1 + class Test_VSecWMSControlWidget(WMSControlWidgetSetup): @pytest.fixture(autouse=True) diff --git a/tests/msui_fixtures.py b/tests/msui_fixtures.py index 5ae59ddc9..b5688c4f2 100644 --- a/tests/msui_fixtures.py +++ b/tests/msui_fixtures.py @@ -33,7 +33,7 @@ # tests.constants sets MSUI_CONFIG_PATH, which mslib.msui.utils.config needs at import time from tests.constants import create_msui_settings_file -from mslib.msui.utils.config import modify_config_file, read_config_file +from mslib.msui.utils.config import modify_config_file, read_config_file, save_settings_qsettings class TestKeyring(keyring.backend.KeyringBackend): @@ -70,9 +70,10 @@ def keyring_reset(): @pytest.fixture(autouse=True) def reset_config(): - """Reset the msui configuration file used in the tests after every test.""" + """Reset the msui configuration file and the remembered WMS URLs used in the tests before every test.""" create_msui_settings_file("{}") read_config_file() + save_settings_qsettings("wms", {}) @pytest.fixture