diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index de7b0617..be0a7407 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -32,6 +32,8 @@ This same list is also available inside the app: **Help → Keyboard Shortcuts | Shortcut | Action | |---|---| | `Ctrl+F` | Open filter dialog | +| `F11` | Maximize map / Restore panels | +| `Ctrl+Shift+M` | Pop out map / Dock map back | --- diff --git a/features.json b/features.json index faaac4e3..63df531a 100644 --- a/features.json +++ b/features.json @@ -1,3 +1,4 @@ { - "reverse-geocoding": false + "reverse-geocoding": false, + "map-popout": true } diff --git a/src/opensak/gui/mainwindow.py b/src/opensak/gui/mainwindow.py index fbc7393c..5fa0f8c1 100644 --- a/src/opensak/gui/mainwindow.py +++ b/src/opensak/gui/mainwindow.py @@ -198,6 +198,11 @@ def __init__(self): self._active_filter_name = "" self._trip_planner_win: TripPlannerDialog | None = None self._db_count: int = 0 + self._map_maximized: bool = False + self._pre_maximize_splitter_sizes: list[int] | None = None + self._pre_maximize_bottom_sizes: list[int] | None = None + self._map_popped_out: bool = False + self._map_popout_window = None self._search_timer = QTimer(self) self._search_timer.setSingleShot(True) self._search_timer.timeout.connect(self._refresh_cache_list) @@ -276,6 +281,8 @@ def _setup_ui(self) -> None: self._map_widget = MapWidget() self._map_widget.cache_selected.connect(self._on_map_cache_selected) self._map_widget.set_corrected_requested.connect(self._on_set_corrected_from_map) + self._map_widget.maximize_requested.connect(self._toggle_maximize_map) + self._map_widget.popout_requested.connect(self._toggle_popout_map) self._detail_panel.waypoints_tab_shown.connect(self._map_widget.show_waypoint_markers) self._detail_panel.waypoints_tab_hidden.connect(self._map_widget.clear_waypoint_markers) self._map_widget.setMinimumWidth(300) @@ -438,6 +445,20 @@ def _setup_menu(self) -> None: act_columns.triggered.connect(self._open_column_chooser) view_menu.addAction(act_columns) + view_menu.addSeparator() + + self._act_maximize_map = QAction(tr("action_maximize_map"), self) + self._act_maximize_map.setShortcut(QKeySequence("F11")) + self._act_maximize_map.triggered.connect(self._toggle_maximize_map) + view_menu.addAction(self._act_maximize_map) + + import opensak.utils.flags as flags + self._act_popout_map = QAction(tr("action_popout_map"), self) + self._act_popout_map.setShortcut(QKeySequence("Ctrl+Shift+M")) + self._act_popout_map.triggered.connect(self._toggle_popout_map) + self._act_popout_map.setVisible(flags.map_popout) + view_menu.addAction(self._act_popout_map) + # ── Funktioner ──────────────────────────────────────────────────────── tools_menu = menubar.addMenu(tr("menu_tools")) @@ -712,6 +733,20 @@ def _setup_toolbar(self) -> None: tb.addSeparator() + # Maksimér kort + self._act_tb_maximize_map = QAction("⛶", self) + self._act_tb_maximize_map.setToolTip(tr("toolbar_maximize_map_tooltip")) + self._act_tb_maximize_map.triggered.connect(self._toggle_maximize_map) + tb.addAction(self._act_tb_maximize_map) + + # Pop-out kort (feature-gated) + import opensak.utils.flags as flags + self._act_tb_popout_map = QAction("⧉", self) + self._act_tb_popout_map.setToolTip(tr("toolbar_popout_map_tooltip")) + self._act_tb_popout_map.triggered.connect(self._toggle_popout_map) + self._act_tb_popout_map.setVisible(flags.map_popout) + tb.addAction(self._act_tb_popout_map) + # Indstillinger — kun ikon settings_act = QAction("⚙", self) settings_act.setToolTip(tr("action_settings").replace("&", "").replace("…", "")) @@ -957,7 +992,112 @@ def _save_splitter_ratios(self) -> None: if total_h > 0: s.bottom_splitter_ratio_left = sizes_h[0] / total_h + def _toggle_maximize_map(self) -> None: + """Toggle map between maximized (full window) and normal panel layout.""" + if self._map_maximized: + # Restore previous layout + self._cache_table.setVisible(True) + self._info_bar.setVisible(True) + self._detail_panel.setVisible(True) + if self._pre_maximize_splitter_sizes: + self._splitter.setSizes(self._pre_maximize_splitter_sizes) + if self._pre_maximize_bottom_sizes: + self._bottom_splitter.setSizes(self._pre_maximize_bottom_sizes) + self._map_maximized = False + self._act_maximize_map.setText(tr("action_maximize_map")) + self._act_tb_maximize_map.setText("⛶") + self._act_tb_maximize_map.setToolTip(tr("toolbar_maximize_map_tooltip")) + else: + # Save current sizes and maximize map + self._pre_maximize_splitter_sizes = self._splitter.sizes() + self._pre_maximize_bottom_sizes = self._bottom_splitter.sizes() + self._cache_table.setVisible(False) + self._info_bar.setVisible(False) + self._detail_panel.setVisible(False) + total_v = sum(self._splitter.sizes()) + self._splitter.setSizes([0, total_v]) + total_h = sum(self._bottom_splitter.sizes()) + self._bottom_splitter.setSizes([0, total_h]) + self._map_maximized = True + self._act_maximize_map.setText(tr("action_restore_map")) + self._act_tb_maximize_map.setText("◻") + self._act_tb_maximize_map.setToolTip(tr("toolbar_restore_map_tooltip")) + + def _toggle_popout_map(self) -> None: + """Pop out the map to its own floating window, or dock it back.""" + if self._map_popped_out: + self._dock_map_back() + else: + self._popout_map() + + def _popout_map(self) -> None: + """Move the map widget into a separate floating window.""" + # Guard against double-invocation (e.g. rapid double-click) + if self._map_popped_out: + return + # If map is maximized, restore first + if self._map_maximized: + self._toggle_maximize_map() + + from opensak.gui.map_popout import MapPopoutWindow + self._map_popout_window = MapPopoutWindow(self) + self._map_popout_window.closed.connect(self._on_popout_closed) + + # Reparent map widget into the pop-out window + self._map_popout_window.take_widget(self._map_widget) + self._map_popout_window.show() + + self._map_popped_out = True + self._act_popout_map.setText(tr("action_dock_map")) + self._act_tb_popout_map.setText("↩") + self._act_tb_popout_map.setToolTip(tr("toolbar_dock_map_tooltip")) + # Disable maximize while popped out + self._act_maximize_map.setEnabled(False) + self._act_tb_maximize_map.setEnabled(False) + + def _dock_map_back(self) -> None: + """Return the map widget to the main window's bottom splitter.""" + # Guard against re-entrant calls (closeEvent → closed signal → here again) + if not self._map_popped_out: + return + + self._map_popped_out = False + + if self._map_popout_window: + # Save geometry once (only here, not in closeEvent to avoid duplication) + self._map_popout_window.save_geometry_to_settings() + get_settings().sync() + # Disconnect before closing to prevent re-entrant closed signal + self._map_popout_window.closed.disconnect(self._on_popout_closed) + + # Reparent map widget back into the bottom splitter at position 1 (right) + self._bottom_splitter.insertWidget(1, self._map_widget) + self._map_widget.setMinimumWidth(300) + self._map_widget.show() + + if self._map_popout_window: + self._map_popout_window.close() + self._map_popout_window.deleteLater() + self._map_popout_window = None + + self._act_popout_map.setText(tr("action_popout_map")) + self._act_tb_popout_map.setText("⧉") + self._act_tb_popout_map.setToolTip(tr("toolbar_popout_map_tooltip")) + # Re-enable maximize + self._act_maximize_map.setEnabled(True) + self._act_tb_maximize_map.setEnabled(True) + + def _on_popout_closed(self) -> None: + """Handle the user closing the pop-out window via its close button.""" + self._dock_map_back() + def closeEvent(self, event) -> None: + # Restore normal layout before saving so ratios reflect the user's + # intended panel sizes, not the maximized state. + if self._map_popped_out: + self._dock_map_back() + if self._map_maximized: + self._toggle_maximize_map() s = get_settings() s.window_geometry = self.saveGeometry() s.window_state = self.saveState(2) @@ -1444,6 +1584,8 @@ def _setup_shortcut_registry(self) -> None: ("trip_planner", "shortcut_trip_planner", [self._act_trip_planner]), ("coord_converter", "shortcut_coord_converter", [self._act_coord_converter]), ("projection", "shortcut_projection", [self._act_projection]), + ("maximize_map", "shortcut_maximize_map", [self._act_maximize_map]), + ("popout_map", "shortcut_popout_map", [self._act_popout_map]), ] def _apply_saved_shortcuts(self) -> None: diff --git a/src/opensak/gui/map_popout.py b/src/opensak/gui/map_popout.py new file mode 100644 index 00000000..4656acef --- /dev/null +++ b/src/opensak/gui/map_popout.py @@ -0,0 +1,58 @@ +""" +src/opensak/gui/map_popout.py — Floating pop-out window for the map. + +Hosts the MapWidget in its own top-level window so the user can resize it +freely or move it to a second monitor. +""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QMainWindow, QVBoxLayout, QWidget + +from opensak.gui.settings import get_settings +from opensak.lang import tr + + +class MapPopoutWindow(QMainWindow): + """Independent window that hosts the MapWidget when popped out.""" + + closed = Signal() # emitted when the user closes the pop-out window + + def __init__(self, parent=None): + # Use Qt.Window flag so it behaves as an independent window while + # still grouping with the parent in the taskbar on Windows. + super().__init__(parent, Qt.WindowType.Window) + self.setWindowTitle(tr("map_popout_title")) + self.setMinimumSize(500, 400) + self._central = QWidget() + self._layout = QVBoxLayout(self._central) + self._layout.setContentsMargins(0, 0, 0, 0) + self.setCentralWidget(self._central) + self._restore_geometry() + + def take_widget(self, widget: QWidget) -> None: + """Reparent a widget into this pop-out window.""" + self._layout.addWidget(widget) + widget.show() + + def save_geometry_to_settings(self) -> None: + """Persist current geometry to settings (base64 string).""" + s = get_settings() + s.map_popout_geometry = bytes(self.saveGeometry().toBase64().data()).decode("ascii") + + def _restore_geometry(self) -> None: + from PySide6.QtCore import QByteArray + s = get_settings() + geo = s.map_popout_geometry + if geo: + if isinstance(geo, (bytes, bytearray, QByteArray)): + self.restoreGeometry(geo) + else: + self.restoreGeometry(QByteArray.fromBase64(geo.encode("ascii"))) + else: + self.resize(900, 700) + + def closeEvent(self, event) -> None: + self.closed.emit() + event.accept() diff --git a/src/opensak/gui/map_widget.py b/src/opensak/gui/map_widget.py index 8ecd33c1..8b7cafa1 100644 --- a/src/opensak/gui/map_widget.py +++ b/src/opensak/gui/map_widget.py @@ -51,6 +51,9 @@ class MapBridge(QObject): cache_clicked = Signal(str) # gc_code # Signal afsendt når brugeren højreklikker på kortet (ikke på en pin) map_right_clicked = Signal(float, float) # lat, lon + # Signals for map control buttons (maximize / pop-out) + maximize_clicked = Signal() + popout_clicked = Signal() @Slot(str) def on_cache_clicked(self, gc_code: GcCode) -> None: @@ -62,6 +65,16 @@ def on_map_right_click(self, lat: float, lon: float) -> None: """Kaldes fra JavaScript når brugeren højreklikker på kortet.""" self.map_right_clicked.emit(lat, lon) + @Slot() + def on_maximize_clicked(self) -> None: + """Kaldes fra JavaScript når maximize-knappen klikkes.""" + self.maximize_clicked.emit() + + @Slot() + def on_popout_clicked(self) -> None: + """Kaldes fra JavaScript når pop-out-knappen klikkes.""" + self.popout_clicked.emit() + # ── HTML template med Leaflet.js ────────────────────────────────────────────── @@ -116,6 +129,25 @@ def on_map_right_click(self, lat: float, lon: float) -> None: text-align: center; line-height: 22px; } + .leaflet-control-mapactions a { + width: 30px; + height: 30px; + line-height: 30px; + text-align: center; + font-size: 16px; + display: block; + text-decoration: none; + color: #333; + background: #fff; + border-bottom: 1px solid #ccc; + cursor: pointer; + } + .leaflet-control-mapactions a:hover { + background: #f4f4f4; + } + .leaflet-control-mapactions a:last-child { + border-bottom: none; + } @@ -173,6 +205,41 @@ def on_map_right_click(self, lat: float, lon: float) -> None: } }); +// ── Map action buttons (maximize / pop-out) ────────────────────────────────── +L.Control.MapActions = L.Control.extend({ + options: { position: 'topleft' }, + onAdd: function(map) { + var container = L.DomUtil.create('div', 'leaflet-control-mapactions leaflet-bar'); + + var maxBtn = L.DomUtil.create('a', '', container); + maxBtn.innerHTML = '⛶'; + maxBtn.title = 'MAP_TIP_MAXIMIZE'; + maxBtn.href = '#'; + maxBtn.setAttribute('role', 'button'); + L.DomEvent.disableClickPropagation(maxBtn); + L.DomEvent.on(maxBtn, 'click', function(e) { + L.DomEvent.preventDefault(e); + if (bridge) bridge.on_maximize_clicked(); + }); + + if (MAP_POPOUT_ENABLED) { + var popBtn = L.DomUtil.create('a', '', container); + popBtn.innerHTML = '⧉'; + popBtn.title = 'MAP_TIP_POPOUT'; + popBtn.href = '#'; + popBtn.setAttribute('role', 'button'); + L.DomEvent.disableClickPropagation(popBtn); + L.DomEvent.on(popBtn, 'click', function(e) { + L.DomEvent.preventDefault(e); + if (bridge) bridge.on_popout_clicked(); + }); + } + + return container; + } +}); +new L.Control.MapActions().addTo(map); + // ── Hjælpefunktioner ────────────────────────────────────────────────────────── function makePinIcon(pinHtml, found, corrected) { var wrapper = pinHtml; @@ -439,6 +506,8 @@ class MapWidget(QWidget): cache_selected = Signal(str) # gc_code set_corrected_requested = Signal(str, float, float) # gc_code, lat, lon + maximize_requested = Signal() + popout_requested = Signal() def __init__(self, parent=None): super().__init__(parent) @@ -469,6 +538,8 @@ def _setup_ui(self) -> None: self._channel = None self._bridge = MapBridge() self._bridge.cache_clicked.connect(self.cache_selected) + self._bridge.maximize_clicked.connect(self.maximize_requested) + self._bridge.popout_clicked.connect(self.popout_requested) self._ready = False layout.addWidget(QLabel("Map disabled (headless test mode)")) return @@ -491,6 +562,8 @@ def _setup_ui(self) -> None: self._bridge = MapBridge() self._bridge.cache_clicked.connect(self.cache_selected) self._bridge.map_right_clicked.connect(self._on_map_right_click) + self._bridge.maximize_clicked.connect(self.maximize_requested) + self._bridge.popout_clicked.connect(self.popout_requested) self._channel.registerObject("bridge", self._bridge) self._page.setWebChannel(self._channel) @@ -502,9 +575,7 @@ def _setup_ui(self) -> None: s = get_settings() init_lat = s.home_lat init_lon = s.home_lon - html = MAP_HTML.replace("INIT_LAT", str(init_lat)) - html = html.replace("INIT_LON", str(init_lon)) - html = html.replace("INIT_ZOOM", "12") + html = self._render_map_html(init_lat, init_lon) self._page.setHtml(html, QUrl(f"qrc:///{int(time.time())}")) layout.addWidget(self._view) @@ -546,6 +617,18 @@ def _on_leaflet_ready(self, ready: bool) -> None: self._pending_refresh = None cb() + @staticmethod + def _render_map_html(lat: float, lon: float) -> str: + """Render MAP_HTML template with dynamic values and localized tooltips.""" + import opensak.utils.flags as flags + html = MAP_HTML.replace("INIT_LAT", str(lat)) + html = html.replace("INIT_LON", str(lon)) + html = html.replace("INIT_ZOOM", "12") + html = html.replace("MAP_TIP_MAXIMIZE", tr("toolbar_maximize_map_tooltip")) + html = html.replace("MAP_TIP_POPOUT", tr("toolbar_popout_map_tooltip")) + html = html.replace("MAP_POPOUT_ENABLED", "true" if flags.map_popout else "false") + return html + def _run_js(self, js: str) -> None: """Kør JavaScript i kortvisningen.""" if self._page is None: @@ -672,9 +755,7 @@ def reload_map(self, refresh_callback=None) -> None: s = get_settings() init_lat = s.home_lat init_lon = s.home_lon - html = MAP_HTML.replace("INIT_LAT", str(init_lat)) - html = html.replace("INIT_LON", str(init_lon)) - html = html.replace("INIT_ZOOM", "12") + html = self._render_map_html(init_lat, init_lon) self._ready = False self._page.setHtml(html, QUrl(f"qrc:///{int(time.time())}")) diff --git a/src/opensak/gui/settings.py b/src/opensak/gui/settings.py index 9c14fceb..ede57ba7 100644 --- a/src/opensak/gui/settings.py +++ b/src/opensak/gui/settings.py @@ -533,6 +533,14 @@ def bottom_splitter_ratio_left(self) -> float: def bottom_splitter_ratio_left(self, value: float) -> None: get_store().set("window.bottom_splitter_ratio_left", float(value)) + @property + def map_popout_geometry(self): + return get_store().get("window.map_popout_geometry", None) + + @map_popout_geometry.setter + def map_popout_geometry(self, value) -> None: + get_store().set("window.map_popout_geometry", value) + # ── Search thresholds ───────────────────────────────────────────────────── @property diff --git a/src/opensak/lang/cs.py b/src/opensak/lang/cs.py index 0cf6c1b8..56fa49aa 100644 --- a/src/opensak/lang/cs.py +++ b/src/opensak/lang/cs.py @@ -69,6 +69,15 @@ "action_filter": "🔍 &Nastavit filtr…", "action_clear_filter": "❌ &Zrušit filtr", "action_columns": "&Vybrat sloupce…", + "action_maximize_map": "⛶ &Maximalizovat mapu", + "action_restore_map": "◻ &Obnovit panely", + "toolbar_maximize_map_tooltip": "Maximalizovat mapu (F11)", + "toolbar_restore_map_tooltip": "Obnovit panely (F11)", + "action_popout_map": "⧉ &Odpojit mapu", + "action_dock_map": "↩ &Připojit mapu zpět", + "toolbar_popout_map_tooltip": "Odpojit mapu do vlastního okna (Ctrl+Shift+M)", + "toolbar_dock_map_tooltip": "Připojit mapu zpět (Ctrl+Shift+M)", + "map_popout_title": "OpenSAK — Mapa", # Tools menu "action_settings": "&Nastavení…", @@ -107,6 +116,8 @@ "shortcut_trip_planner": "Plánování trasy", "shortcut_coord_converter": "Převodník souřadnic", "shortcut_projection": "Projekce souřadnic", + "shortcut_maximize_map": "Maximalizovat mapu", + "shortcut_popout_map": "Odpojit mapu", # ── Geocaching Tools menu ───────────────────────────────────────────────── "menu_gc_tools": "&Nástroje", diff --git a/src/opensak/lang/da.py b/src/opensak/lang/da.py index e80ce3a5..93dbac3b 100644 --- a/src/opensak/lang/da.py +++ b/src/opensak/lang/da.py @@ -69,6 +69,15 @@ "action_filter": "🔍 &Sæt filter…", "action_clear_filter": "❌ &Nulstil filter", "action_columns": "&Vælg kolonner…", + "action_maximize_map": "⛶ &Maksimér kort", + "action_restore_map": "◻ &Gendan paneler", + "toolbar_maximize_map_tooltip": "Maksimér kort (F11)", + "toolbar_restore_map_tooltip": "Gendan paneler (F11)", + "action_popout_map": "⧉ &Pop kort ud", + "action_dock_map": "↩ &Dok kort tilbage", + "toolbar_popout_map_tooltip": "Pop kort ud i eget vindue (Ctrl+Shift+M)", + "toolbar_dock_map_tooltip": "Dok kort tilbage i hovedvinduet (Ctrl+Shift+M)", + "map_popout_title": "OpenSAK — Kort", # Funktioner-menu "action_settings": "&Indstillinger…", @@ -107,6 +116,8 @@ "shortcut_trip_planner": "Turplanlægger", "shortcut_coord_converter": "Koordinatkonverter", "shortcut_projection": "Koordinatprojektion", + "shortcut_maximize_map": "Maksimér kort", + "shortcut_popout_map": "Pop kort ud", # ── Geocaching Værktøjer-menu ───────────────────────────────────────────── "menu_gc_tools": "&Værktøjer", diff --git a/src/opensak/lang/de.py b/src/opensak/lang/de.py index a5768b73..0dd7564a 100644 --- a/src/opensak/lang/de.py +++ b/src/opensak/lang/de.py @@ -69,6 +69,15 @@ "action_filter": "🔍 &Filter einstellen…", "action_clear_filter": "❌ &Filter löschen", "action_columns": "&Spalten wählen…", + "action_maximize_map": "⛶ &Karte maximieren", + "action_restore_map": "◻ &Panels wiederherstellen", + "toolbar_maximize_map_tooltip": "Karte maximieren (F11)", + "toolbar_restore_map_tooltip": "Panels wiederherstellen (F11)", + "action_popout_map": "⧉ &Karte ablösen", + "action_dock_map": "↩ &Karte zurückholen", + "toolbar_popout_map_tooltip": "Karte in eigenes Fenster (Ctrl+Shift+M)", + "toolbar_dock_map_tooltip": "Karte zurück ins Hauptfenster (Ctrl+Shift+M)", + "map_popout_title": "OpenSAK — Karte", # Tools menu "action_settings": "&Einstellungen…", @@ -107,6 +116,8 @@ "shortcut_trip_planner": "Tourenplaner", "shortcut_coord_converter": "Koordinatenkonverter", "shortcut_projection": "Koordinatenprojektion", + "shortcut_maximize_map": "Karte maximieren", + "shortcut_popout_map": "Karte ablösen", # ── Geocaching Tools menu ───────────────────────────────────────────────── "menu_gc_tools": "&Tools", diff --git a/src/opensak/lang/en.py b/src/opensak/lang/en.py index 6beab5c9..063f1de9 100644 --- a/src/opensak/lang/en.py +++ b/src/opensak/lang/en.py @@ -69,6 +69,15 @@ "action_filter": "🔍 &Set filter…", "action_clear_filter": "❌ &Clear filter", "action_columns": "&Choose columns…", + "action_maximize_map": "⛶ &Maximize map", + "action_restore_map": "◻ &Restore panels", + "toolbar_maximize_map_tooltip": "Maximize map (F11)", + "toolbar_restore_map_tooltip": "Restore panels (F11)", + "action_popout_map": "⧉ &Pop out map", + "action_dock_map": "↩ &Dock map back", + "toolbar_popout_map_tooltip": "Pop out map to own window (Ctrl+Shift+M)", + "toolbar_dock_map_tooltip": "Dock map back into main window (Ctrl+Shift+M)", + "map_popout_title": "OpenSAK — Map", # Tools menu "action_settings": "&Settings…", @@ -107,6 +116,8 @@ "shortcut_trip_planner": "Trip Planner", "shortcut_coord_converter": "Coordinate Converter", "shortcut_projection": "Coordinate Projection", + "shortcut_maximize_map": "Maximize Map", + "shortcut_popout_map": "Pop Out Map", # ── Geocaching Tools menu ───────────────────────────────────────────────── "menu_gc_tools": "&Tools", diff --git a/src/opensak/lang/fr.py b/src/opensak/lang/fr.py index da07a652..9f3ea5c3 100644 --- a/src/opensak/lang/fr.py +++ b/src/opensak/lang/fr.py @@ -69,6 +69,15 @@ "action_filter": "🔍 &Définir le filtre…", "action_clear_filter": "❌ &Effacer le filtre", "action_columns": "&Choisir les colonnes…", + "action_maximize_map": "⛶ &Agrandir la carte", + "action_restore_map": "◻ &Restaurer les panneaux", + "toolbar_maximize_map_tooltip": "Agrandir la carte (F11)", + "toolbar_restore_map_tooltip": "Restaurer les panneaux (F11)", + "action_popout_map": "⧉ &Détacher la carte", + "action_dock_map": "↩ &Réintégrer la carte", + "toolbar_popout_map_tooltip": "Détacher la carte (Ctrl+Shift+M)", + "toolbar_dock_map_tooltip": "Réintégrer la carte (Ctrl+Shift+M)", + "map_popout_title": "OpenSAK — Carte", # Tools menu "action_settings": "&Paramètres…", @@ -107,6 +116,8 @@ "shortcut_trip_planner": "Planificateur de voyage", "shortcut_coord_converter": "Convertisseur de coordonnées", "shortcut_projection": "Projection de coordonnées", + "shortcut_maximize_map": "Agrandir la carte", + "shortcut_popout_map": "Détacher la carte", # ── Menu Outils de géocaching ───────────────────────────────────────────── "menu_gc_tools": "&Outils géo", diff --git a/src/opensak/lang/nl.py b/src/opensak/lang/nl.py index 5657a399..be397b6c 100644 --- a/src/opensak/lang/nl.py +++ b/src/opensak/lang/nl.py @@ -72,6 +72,15 @@ "action_filter": "🔍 &Filter instellen…", "action_clear_filter": "❌ &Filter wissen", "action_columns": "&Kolommen kiezen…", + "action_maximize_map": "⛶ &Kaart maximaliseren", + "action_restore_map": "◻ &Panelen herstellen", + "toolbar_maximize_map_tooltip": "Kaart maximaliseren (F11)", + "toolbar_restore_map_tooltip": "Panelen herstellen (F11)", + "action_popout_map": "⧉ &Kaart loskoppelen", + "action_dock_map": "↩ &Kaart terugplaatsen", + "toolbar_popout_map_tooltip": "Kaart in eigen venster (Ctrl+Shift+M)", + "toolbar_dock_map_tooltip": "Kaart terug in hoofdvenster (Ctrl+Shift+M)", + "map_popout_title": "OpenSAK — Kaart", # Tools menu "action_settings": "&Instellingen…", @@ -110,6 +119,8 @@ "shortcut_trip_planner": "Routeplanner", "shortcut_coord_converter": "Coördinatenomzetter", "shortcut_projection": "Coördinatenprojectie", + "shortcut_maximize_map": "Kaart maximaliseren", + "shortcut_popout_map": "Kaart loskoppelen", # ── Geocaching Tools menu ───────────────────────────────────────────────── "menu_gc_tools": "&Extra's", diff --git a/src/opensak/lang/pt.py b/src/opensak/lang/pt.py index 6c40e1a9..4396c2d6 100644 --- a/src/opensak/lang/pt.py +++ b/src/opensak/lang/pt.py @@ -69,6 +69,15 @@ "action_filter": "🔍 &Definir filtro", "action_clear_filter": "❌ &Limpar filtro", "action_columns": "&Escolher colunas…", + "action_maximize_map": "⛶ &Maximizar mapa", + "action_restore_map": "◻ &Restaurar painéis", + "toolbar_maximize_map_tooltip": "Maximizar mapa (F11)", + "toolbar_restore_map_tooltip": "Restaurar painéis (F11)", + "action_popout_map": "⧉ &Destacar mapa", + "action_dock_map": "↩ &Reintegrar mapa", + "toolbar_popout_map_tooltip": "Destacar mapa em janela própria (Ctrl+Shift+M)", + "toolbar_dock_map_tooltip": "Reintegrar mapa na janela principal (Ctrl+Shift+M)", + "map_popout_title": "OpenSAK — Mapa", # Tools menu "action_settings": "&Definições…", @@ -107,6 +116,8 @@ "shortcut_trip_planner": "Planeador de percurso", "shortcut_coord_converter": "Conversor de coordenadas", "shortcut_projection": "Projeção de coordenadas", + "shortcut_maximize_map": "Maximizar mapa", + "shortcut_popout_map": "Destacar mapa", # ── Geocaching Tools menu ───────────────────────────────────────────────── "menu_gc_tools": "&Ferramentas de geocaching", diff --git a/src/opensak/lang/se.py b/src/opensak/lang/se.py index 9ce765ee..07f79507 100644 --- a/src/opensak/lang/se.py +++ b/src/opensak/lang/se.py @@ -69,6 +69,15 @@ "action_filter": "🔍 &Sätt filter…", "action_clear_filter": "❌ &Rensa filter", "action_columns": "&Välj kolumner…", + "action_maximize_map": "⛶ &Maximera karta", + "action_restore_map": "◻ &Återställ paneler", + "toolbar_maximize_map_tooltip": "Maximera karta (F11)", + "toolbar_restore_map_tooltip": "Återställ paneler (F11)", + "action_popout_map": "⧉ &Koppla loss karta", + "action_dock_map": "↩ &Docka tillbaka karta", + "toolbar_popout_map_tooltip": "Koppla loss kartan till eget fönster (Ctrl+Shift+M)", + "toolbar_dock_map_tooltip": "Docka tillbaka kartan (Ctrl+Shift+M)", + "map_popout_title": "OpenSAK — Karta", # Tools menu "action_settings": "&Inställningar…", @@ -107,6 +116,8 @@ "shortcut_trip_planner": "Reseplanerare", "shortcut_coord_converter": "Koordinatomvandlare", "shortcut_projection": "Koordinatprojektion", + "shortcut_maximize_map": "Maximera karta", + "shortcut_popout_map": "Koppla loss karta", # ── Geocaching Tools menu ───────────────────────────────────────────────── "menu_gc_tools": "&Verktyg", diff --git a/src/opensak/utils/flags.py b/src/opensak/utils/flags.py index 17923847..dce54347 100644 --- a/src/opensak/utils/flags.py +++ b/src/opensak/utils/flags.py @@ -29,6 +29,7 @@ _RELEASE_DEFAULTS: dict[str, bool] = { "reverse-geocoding": False, + "map-popout": True, } @@ -70,3 +71,4 @@ def _load() -> dict[str, bool]: # ── Public flag attributes ──────────────────────────────────────────────────── reverse_geocoding: bool = _flags["reverse-geocoding"] +map_popout: bool = _flags["map-popout"] diff --git a/tests/unit-tests/test_flags.py b/tests/unit-tests/test_flags.py index 8d8e486e..7123f15b 100644 --- a/tests/unit-tests/test_flags.py +++ b/tests/unit-tests/test_flags.py @@ -10,6 +10,7 @@ class TestLoad: def test_absent_file_returns_release_defaults(self, no_features_file): assert flags_module._flags == { "reverse-geocoding": False, + "map-popout": True, } def test_present_file_overrides_defaults(self, patch_features_file): @@ -23,6 +24,7 @@ def test_malformed_json_falls_back_to_defaults(self, tmp_path, monkeypatch): result = flags_module._load() assert result == { "reverse-geocoding": False, + "map-popout": True, } def test_unknown_keys_in_file_are_ignored(self, patch_features_file): diff --git a/tests/unit-tests/test_map_maximize_popout.py b/tests/unit-tests/test_map_maximize_popout.py new file mode 100644 index 00000000..27be5fc8 --- /dev/null +++ b/tests/unit-tests/test_map_maximize_popout.py @@ -0,0 +1,527 @@ +# tests/unit-tests/test_map_maximize_popout.py — tests for map maximize & pop-out features. + +from types import SimpleNamespace +from unittest.mock import patch, MagicMock + +import pytest + +pytest.importorskip("pytestqt") + +from opensak.gui.mainwindow import MainWindow +from opensak.lang import tr + + +# ── fixtures ────────────────────────────────────────────────────────────────── + + +@pytest.fixture(autouse=True) +def _quiet_startup(monkeypatch): + """Disable delayed singleShot callbacks that fire mid-test.""" + monkeypatch.setattr(MainWindow, "_initial_load", lambda self: None) + monkeypatch.setattr(MainWindow, "_check_update_background", lambda self: None) + monkeypatch.setattr(MainWindow, "_check_setup_complete", lambda self: None) + + +@pytest.fixture +def window(qtbot, tmp_path, monkeypatch): + """MainWindow on a throwaway empty DB.""" + import opensak.db.manager as mgr_module + from opensak.db.database import init_db + from opensak.lang import load_language + from tests.data import make_fake_manager + + load_language("en") + + db_path = tmp_path / "test_map.db" + init_db(db_path=db_path) + monkeypatch.setattr(mgr_module, "_manager", make_fake_manager(db_path, name="MapTest")) + + win = MainWindow() + qtbot.addWidget(win) + win.show() + qtbot.waitExposed(win) + + yield win + + win.close() + mgr_module._manager = None + + +# ── maximize map tests ──────────────────────────────────────────────────────── + + +class TestMaximizeMap: + def test_initial_state_not_maximized(self, window): + assert window._map_maximized is False + assert window._cache_table.isVisible() + assert window._info_bar.isVisible() + assert window._detail_panel.isVisible() + + def test_maximize_hides_panels(self, window): + window._toggle_maximize_map() + + assert window._map_maximized is True + assert not window._cache_table.isVisible() + assert not window._info_bar.isVisible() + assert not window._detail_panel.isVisible() + + def test_maximize_saves_splitter_sizes(self, window): + original_v = window._splitter.sizes() + original_h = window._bottom_splitter.sizes() + + window._toggle_maximize_map() + + assert window._pre_maximize_splitter_sizes == original_v + assert window._pre_maximize_bottom_sizes == original_h + + def test_maximize_sets_splitter_to_full_bottom(self, window): + window._toggle_maximize_map() + + sizes_v = window._splitter.sizes() + assert sizes_v[0] == 0 + assert sizes_v[1] > 0 + + def test_maximize_sets_bottom_splitter_to_full_right(self, window): + window._toggle_maximize_map() + + sizes_h = window._bottom_splitter.sizes() + assert sizes_h[0] == 0 + assert sizes_h[1] > 0 + + def test_maximize_updates_menu_text(self, window): + window._toggle_maximize_map() + + assert window._act_maximize_map.text() == tr("action_restore_map") + + def test_maximize_updates_toolbar_text(self, window): + window._toggle_maximize_map() + + assert window._act_tb_maximize_map.text() == "◻" + assert window._act_tb_maximize_map.toolTip() == tr("toolbar_restore_map_tooltip") + + def test_restore_shows_panels(self, window): + window._toggle_maximize_map() + window._toggle_maximize_map() + + assert window._map_maximized is False + assert window._cache_table.isVisible() + assert window._info_bar.isVisible() + assert window._detail_panel.isVisible() + + def test_restore_restores_splitter_sizes(self, window): + original_v = window._splitter.sizes() + original_h = window._bottom_splitter.sizes() + + window._toggle_maximize_map() + window._toggle_maximize_map() + + assert window._splitter.sizes() == original_v + assert window._bottom_splitter.sizes() == original_h + + def test_restore_updates_menu_text(self, window): + window._toggle_maximize_map() + window._toggle_maximize_map() + + assert window._act_maximize_map.text() == tr("action_maximize_map") + + def test_restore_updates_toolbar_text(self, window): + window._toggle_maximize_map() + window._toggle_maximize_map() + + assert window._act_tb_maximize_map.text() == "⛶" + assert window._act_tb_maximize_map.toolTip() == tr("toolbar_maximize_map_tooltip") + + def test_close_event_restores_if_maximized(self, window, qtbot): + window._toggle_maximize_map() + assert window._map_maximized is True + + # closeEvent should restore before saving + from PySide6.QtGui import QCloseEvent + event = QCloseEvent() + window.closeEvent(event) + + assert window._map_maximized is False + + def test_save_splitter_ratios_not_corrupted_by_maximize(self, window): + from opensak.gui.settings import get_settings + + # Set known sizes + window._splitter.setSizes([300, 400]) + window._bottom_splitter.setSizes([400, 500]) + window._save_splitter_ratios() + + s = get_settings() + ratio_v = s.splitter_ratio_top + ratio_h = s.bottom_splitter_ratio_left + + # Maximize, then close (which restores + saves) + window._toggle_maximize_map() + from PySide6.QtGui import QCloseEvent + event = QCloseEvent() + window.closeEvent(event) + + # Ratios should match original, not 0.0 + assert abs(s.splitter_ratio_top - ratio_v) < 0.01 + assert abs(s.bottom_splitter_ratio_left - ratio_h) < 0.01 + + +# ── pop-out map tests ───────────────────────────────────────────────────────── + + +class TestPopoutMap: + def test_initial_state_not_popped_out(self, window): + assert window._map_popped_out is False + assert window._map_popout_window is None + + def test_popout_creates_window(self, window): + window._toggle_popout_map() + + assert window._map_popped_out is True + assert window._map_popout_window is not None + assert window._map_popout_window.isVisible() + + def test_popout_reparents_map_widget(self, window): + window._toggle_popout_map() + + # Map widget's parent should be inside the popout window + assert window._map_widget.parent() is not None + assert window._map_widget.isVisible() + # The map widget should NOT be in the bottom splitter anymore + assert window._bottom_splitter.indexOf(window._map_widget) == -1 + + def test_popout_updates_menu_text(self, window): + window._toggle_popout_map() + + assert window._act_popout_map.text() == tr("action_dock_map") + + def test_popout_updates_toolbar_text(self, window): + window._toggle_popout_map() + + assert window._act_tb_popout_map.text() == "↩" + assert window._act_tb_popout_map.toolTip() == tr("toolbar_dock_map_tooltip") + + def test_popout_disables_maximize(self, window): + window._toggle_popout_map() + + assert not window._act_maximize_map.isEnabled() + assert not window._act_tb_maximize_map.isEnabled() + + def test_dock_back_restores_map_to_splitter(self, window): + window._toggle_popout_map() + window._toggle_popout_map() + + assert window._map_popped_out is False + assert window._map_popout_window is None + # Map should be back in the bottom splitter (index 1) + assert window._bottom_splitter.indexOf(window._map_widget) == 1 + + def test_dock_back_updates_menu_text(self, window): + window._toggle_popout_map() + window._toggle_popout_map() + + assert window._act_popout_map.text() == tr("action_popout_map") + + def test_dock_back_updates_toolbar_text(self, window): + window._toggle_popout_map() + window._toggle_popout_map() + + assert window._act_tb_popout_map.text() == "⧉" + assert window._act_tb_popout_map.toolTip() == tr("toolbar_popout_map_tooltip") + + def test_dock_back_re_enables_maximize(self, window): + window._toggle_popout_map() + window._toggle_popout_map() + + assert window._act_maximize_map.isEnabled() + assert window._act_tb_maximize_map.isEnabled() + + def test_popout_restores_maximize_first(self, window): + # Maximize then pop out — should restore normal layout first + window._toggle_maximize_map() + assert window._map_maximized is True + + window._toggle_popout_map() + + assert window._map_maximized is False + assert window._map_popped_out is True + assert window._cache_table.isVisible() + assert window._info_bar.isVisible() + assert window._detail_panel.isVisible() + + def test_on_popout_closed_docks_back(self, window): + window._toggle_popout_map() + assert window._map_popped_out is True + + # Simulate closing the pop-out window + window._on_popout_closed() + + assert window._map_popped_out is False + assert window._bottom_splitter.indexOf(window._map_widget) == 1 + + def test_close_event_docks_if_popped_out(self, window): + window._toggle_popout_map() + assert window._map_popped_out is True + + from PySide6.QtGui import QCloseEvent + event = QCloseEvent() + window.closeEvent(event) + + assert window._map_popped_out is False + + def test_popout_geometry_saved_on_dock(self, window): + from opensak.gui.settings import get_settings + + window._toggle_popout_map() + # Resize the pop-out window + window._map_popout_window.resize(1000, 800) + + window._toggle_popout_map() # dock back + + s = get_settings() + assert s.map_popout_geometry is not None + assert len(s.map_popout_geometry) > 0 + + def test_popout_geometry_restored_on_reopen(self, window): + from opensak.gui.settings import get_settings + + window._toggle_popout_map() + window._map_popout_window.resize(1000, 800) + window._toggle_popout_map() # dock back, saves geometry + + # Pop out again — should restore saved geometry + window._toggle_popout_map() + assert window._map_popout_window is not None + # The window exists and is visible (geometry restore is best-effort) + assert window._map_popout_window.isVisible() + + def test_popout_window_title(self, window): + window._toggle_popout_map() + + assert window._map_popout_window.windowTitle() == tr("map_popout_title") + + def test_double_popout_is_no_op(self, window): + window._toggle_popout_map() + first_win = window._map_popout_window + + # Calling _popout_map again should be a no-op (guard) + window._popout_map() + + assert window._map_popout_window is first_win + + def test_double_dock_is_no_op(self, window): + # Docking when not popped out should be a no-op + window._dock_map_back() + assert window._map_popped_out is False + + def test_re_entrant_dock_safe(self, window): + window._toggle_popout_map() + # Simulate re-entrant scenario: dock_map_back called twice + window._dock_map_back() + window._dock_map_back() # should not raise + assert window._map_popped_out is False + + +# ── MapPopoutWindow unit tests ──────────────────────────────────────────────── + + +class TestMapPopoutWindow: + def test_default_size_without_saved_geometry(self, qtbot): + from opensak.gui.map_popout import MapPopoutWindow + + win = MapPopoutWindow(None) + qtbot.addWidget(win) + + assert win.minimumWidth() == 500 + assert win.minimumHeight() == 400 + # Default size is 900x700 + assert win.width() == 900 + assert win.height() == 700 + + def test_close_emits_signal(self, qtbot): + from opensak.gui.map_popout import MapPopoutWindow + + win = MapPopoutWindow(None) + qtbot.addWidget(win) + win.show() + + with qtbot.waitSignal(win.closed, timeout=1000): + win.close() + + def test_close_saves_geometry(self, qtbot): + from opensak.gui.map_popout import MapPopoutWindow + from opensak.gui.settings import get_settings + + win = MapPopoutWindow(None) + qtbot.addWidget(win) + win.show() + win.resize(800, 600) + # Geometry is saved via save_geometry_to_settings(), not closeEvent + win.save_geometry_to_settings() + + s = get_settings() + assert s.map_popout_geometry is not None + + def test_take_widget(self, qtbot): + from opensak.gui.map_popout import MapPopoutWindow + from PySide6.QtWidgets import QLabel + + win = MapPopoutWindow(None) + qtbot.addWidget(win) + win.show() + + label = QLabel("test") + win.take_widget(label) + + assert label.isVisible() + assert label.parent() is not None + + def test_restore_geometry_from_saved(self, qtbot): + from opensak.gui.map_popout import MapPopoutWindow + from opensak.gui.settings import get_settings + from PySide6.QtCore import QByteArray + + # First, save a geometry + win1 = MapPopoutWindow(None) + qtbot.addWidget(win1) + win1.show() + win1.resize(1100, 750) + win1.save_geometry_to_settings() + win1.close() + + # Now create a new window — it should restore + win2 = MapPopoutWindow(None) + qtbot.addWidget(win2) + win2.show() + # Just verify it didn't crash and window exists + assert win2.isVisible() + + +# ── MapBridge signal tests ──────────────────────────────────────────────────── + + +class TestMapBridgeSignals: + def test_maximize_signal_emitted(self, qtbot): + from opensak.gui.map_widget import MapBridge + + bridge = MapBridge() + with qtbot.waitSignal(bridge.maximize_clicked, timeout=1000): + bridge.on_maximize_clicked() + + def test_popout_signal_emitted(self, qtbot): + from opensak.gui.map_widget import MapBridge + + bridge = MapBridge() + with qtbot.waitSignal(bridge.popout_clicked, timeout=1000): + bridge.on_popout_clicked() + + +# ── MapWidget signal forwarding tests ───────────────────────────────────────── + + +class TestMapWidgetSignals: + def test_maximize_requested_forwarded(self, window, qtbot): + with qtbot.waitSignal(window._map_widget.maximize_requested, timeout=1000): + window._map_widget._bridge.on_maximize_clicked() + + def test_popout_requested_forwarded(self, window, qtbot): + with qtbot.waitSignal(window._map_widget.popout_requested, timeout=1000): + window._map_widget._bridge.on_popout_clicked() + + def test_maximize_button_triggers_toggle(self, window): + # Trigger the bridge signal as if the JS button was clicked + window._map_widget._bridge.on_maximize_clicked() + + assert window._map_maximized is True + + def test_popout_button_triggers_toggle(self, window): + # Trigger the bridge signal as if the JS button was clicked + window._map_widget._bridge.on_popout_clicked() + + assert window._map_popped_out is True + assert window._map_popout_window is not None + + +# ── Shortcut registry tests ─────────────────────────────────────────────────── + + +class TestShortcutRegistry: + def test_maximize_map_in_registry(self, window): + keys = [key for key, _, _ in window._shortcut_registry] + assert "maximize_map" in keys + + def test_popout_map_in_registry(self, window): + keys = [key for key, _, _ in window._shortcut_registry] + assert "popout_map" in keys + + def test_maximize_shortcut_is_f11(self, window): + from PySide6.QtGui import QKeySequence + assert window._act_maximize_map.shortcut() == QKeySequence("F11") + + def test_popout_shortcut_is_ctrl_shift_m(self, window): + from PySide6.QtGui import QKeySequence + assert window._act_popout_map.shortcut() == QKeySequence("Ctrl+Shift+M") + + +# ── Settings property tests ─────────────────────────────────────────────────── + + +class TestMapPopoutSettings: + def test_map_popout_geometry_default_none(self): + from opensak.gui.settings import get_settings + s = get_settings() + assert s.map_popout_geometry is None + + def test_map_popout_geometry_roundtrip(self): + from opensak.gui.settings import get_settings + s = get_settings() + s.map_popout_geometry = "AAAA" + assert s.map_popout_geometry == "AAAA" + + def test_map_popout_geometry_set_none(self): + from opensak.gui.settings import get_settings + s = get_settings() + s.map_popout_geometry = "test" + s.map_popout_geometry = None + assert s.map_popout_geometry is None + + +# ── Feature flag tests ──────────────────────────────────────────────────────── + + +class TestFeatureFlag: + def test_map_popout_flag_exists(self): + import opensak.utils.flags as flags + assert hasattr(flags, "map_popout") + + def test_popout_visible_when_flag_enabled(self, window, monkeypatch): + import opensak.utils.flags as flags + # The flag defaults to True so actions should be visible + assert flags.map_popout is True + assert window._act_popout_map.isVisible() + assert window._act_tb_popout_map.isVisible() + + def test_map_minimum_width_after_dock(self, window): + window._toggle_popout_map() + window._toggle_popout_map() # dock back + + assert window._map_widget.minimumWidth() == 300 + + def test_map_at_correct_splitter_index_after_dock(self, window): + window._toggle_popout_map() + window._toggle_popout_map() # dock back + + assert window._bottom_splitter.indexOf(window._map_widget) == 1 + + def test_popout_window_has_parent(self, window): + window._toggle_popout_map() + # Pop-out window should have main window as parent (taskbar grouping) + assert window._map_popout_window.parent() is window + + def test_geometry_synced_on_dock(self, window): + from opensak.gui.settings import get_settings + window._toggle_popout_map() + window._toggle_popout_map() # dock back + + s = get_settings() + # After docking, geometry should be persisted (sync called) + assert s.map_popout_geometry is not None