commit e310c017efe9af7653e68c5c36da5c9a07b7947d
parent ce370b652a5ca8eae63504a2cbda1f8ed4f09157
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date: Thu, 22 Oct 2026 09:39:01 +0000
feat(gui): give each Elevation Profile panel its own interpretation buttons
Diffstat:
4 files changed, 551 insertions(+), 427 deletions(-)
diff --git a/README.md b/README.md
@@ -44,36 +44,39 @@ altered). Otherwise points are stored in a memory layer named
1. Load data that supports elevation profiles (e.g. TEM Loader layers).
2. Open the profile panel: **View > Elevation Profile**, then draw a profile
line across your data so the section renders.
-3. Toggle **Interpret on Profile** (toolbar button or **Plugins > Profile
- Interpreter**) on.
+3. Toggle **Interpret on Profile** on. The button sits on the Elevation Profile
+ panel's own toolbar; every open profile panel has its own button.
4. Click on the profile where you interpret a boundary. A PointZ feature is
added to the active layer (if it is a writable PointZ layer) or to the
fallback **Profile interpretations** memory layer. The point's X/Y/Z is
printed to the Python console and shown in the message bar.
5. To remove the point you just placed, click **Undo Point**. While the tool is
- on, this button is added to the Elevation Profile panel's own toolbar; it is
- always available under **Plugins > Profile Interpreter** as well, and stays
- disabled until you have placed at least one point. Repeated clicks walk back
- through the points in reverse order.
+ on, this button is added next to **Interpret on Profile** on the panel
+ toolbar, and stays disabled until you have placed at least one point in that
+ panel. Repeated clicks walk back through the points in reverse order.
6. To move a point, check **Edit Point**, click the point (it is selected), then
click its new position. Its `distance` and `elevation` fields are updated if
the layer has them. Right-click or uncheck **Edit Point** to cancel a
selection.
7. To remove a point, check **Delete Point** and click the point.
8. **Edit Point** and **Delete Point** sit next to **Undo Point** on the panel
- toolbar and in the plugin menu. Only one of them can be checked at a time;
+ toolbar. Only one of them can be checked at a time;
with neither checked, clicks add points. A point counts as clicked when it is
drawn within 10 pixels of the cursor.
9. Toggle the tool off to return to normal pan/zoom on the profile.
+10. With several profile panels open, each one works on its own: it has its own
+ on/off state, Edit/Delete mode and **Undo Point** history, and several
+ panels can be on at once. Points from all panels go to the same target
+ layer.
# Limitations
- When no active PointZ layer is set, interpretation points are stored in an
in-memory fallback layer only; there is no GeoPackage persistence or
per-boundary classification.
-- The plugin grabs the profile canvas by walking the Qt widget tree
- (`findChildren(QgsElevationProfileCanvas)`); there is no official PyQGIS
- accessor. With multiple profile docks open it picks the first visible one.
+- The plugin finds profile panels by walking the Qt widget tree
+ (`findChildren(QgsElevationProfileCanvas)`) whenever a window is added to the
+ QGIS main window; there is no official PyQGIS accessor or signal.
- **Undo Point** only walks back points placed in the current QGIS session, in
reverse order; it does not participate in QGIS's own undo stack (points are
written straight to the data provider, not through an edit buffer).
@@ -81,11 +84,13 @@ altered). Otherwise points are stored in a memory layer named
points; undoing after moving a point deletes the moved point.
- **Edit Point** and **Delete Point** only search the layer new points go to
(the active PointZ layer, otherwise the fallback memory layer). Selecting a
- point for editing replaces that layer's current selection.
-- The panel buttons are placed on the profile panel by locating the
- panel's toolbar in the Qt widget tree. If a QGIS build lays the panel out
- differently, the buttons are silently omitted there and remain in the plugin
- menu.
+ point for editing replaces that layer's current selection, so two panels
+ editing the same layer share one selection highlight.
+- The buttons are placed on a profile panel by locating the panel's toolbar in
+ the Qt widget tree. If a QGIS build lays the panel out differently, that
+ panel gets no buttons.
+- Picking another tool on the panel toolbar (Pan, Identify, Measure) leaves
+ **Interpret on Profile** checked; toggle it off and on again to resume.
- Placing and moving points follow the profile's snap toggle (magnet icon):
with snapping on, the point lands on the snapped crosshair position.
Selecting a point for **Edit Point** or **Delete Point** uses the raw cursor.
diff --git a/profile_interpreter/profile_interpreter.py b/profile_interpreter/profile_interpreter.py
@@ -17,16 +17,18 @@ The conversion path is:
import math
import os
-from qgis.PyQt.QtCore import Qt, QPointF
+from qgis.PyQt.QtCore import QEvent, QObject, QPointF, Qt, QTimer
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QAction, QToolBar
try:
_LEFT_BUTTON = Qt.MouseButton.LeftButton # PyQt6 / QGIS 4
_RIGHT_BUTTON = Qt.MouseButton.RightButton
+ _CHILD_ADDED = QEvent.Type.ChildAdded
except AttributeError:
_LEFT_BUTTON = Qt.LeftButton # PyQt5 / QGIS 3
_RIGHT_BUTTON = Qt.RightButton
+ _CHILD_ADDED = QEvent.ChildAdded
from qgis.core import (
Qgis,
@@ -192,90 +194,81 @@ class _ProfilePickTool(QgsPlotTool):
super().plotReleaseEvent(event)
-class ProfileInterpreterPlugin:
- def __init__(self, iface):
- self.iface = iface
- self._action = None
- self._canvas = None
+class _PanelWatcher(QObject):
+ """Event filter that calls on_change once per burst of children added to the watched window."""
+
+ def __init__(self, on_change):
+ super().__init__()
+ self._on_change = on_change
+ self._pending = False
+
+ def eventFilter(self, watched, event):
+ if event.type() == _CHILD_ADDED and not self._pending:
+ self._pending = True
+ QTimer.singleShot(0, self._fire)
+ return False
+
+ def _fire(self):
+ self._pending = False
+ self._on_change()
+
+
+class _PanelSession:
+ """Interpretation tool, buttons and undo history bound to one Elevation Profile panel."""
+
+ def __init__(self, plugin, canvas, toolbar):
+ self._plugin = plugin
+ self.iface = plugin.iface
+ self._canvas = canvas
+ self._toolbar = toolbar
self._pick_tool = None
self._pan_tool = None
- self._layer = None
- self._undo_action = None
- self._edit_action = None
- self._delete_action = None
- self._panel_toolbar = None
self._history = []
self._mode = 'add'
self._selected = None
- def initGui(self):
icon_path = os.path.join(os.path.dirname(__file__), 'icon.svg')
- self._action = QAction(QIcon(icon_path), 'Interpret on Profile', self.iface.mainWindow())
+ self._action = QAction(QIcon(icon_path), 'Interpret on Profile', toolbar)
+ self._action.setToolTip('Place interpretation points on this elevation profile')
self._action.setCheckable(True)
self._action.toggled.connect(self._set_active)
- self.iface.addPluginToMenu(MENU, self._action)
- self.iface.addToolBarIcon(self._action)
self._undo_action = QAction(
- QgsApplication.getThemeIcon('mActionUndo.svg'),
- 'Undo Point', self.iface.mainWindow())
+ QgsApplication.getThemeIcon('mActionUndo.svg'), 'Undo Point', toolbar)
self._undo_action.setToolTip('Remove the last interpretation point')
self._undo_action.setEnabled(False)
self._undo_action.triggered.connect(self._undo_last_point)
- self.iface.addPluginToMenu(MENU, self._undo_action)
self._edit_action = QAction(
- QgsApplication.getThemeIcon('mActionVertexTool.svg'),
- 'Edit Point', self.iface.mainWindow())
+ QgsApplication.getThemeIcon('mActionVertexTool.svg'), 'Edit Point', toolbar)
self._edit_action.setToolTip(
'Click a point to select it, then click its new position')
self._edit_action.setCheckable(True)
self._edit_action.setEnabled(False)
self._edit_action.toggled.connect(lambda on: self._set_mode('edit', on))
- self.iface.addPluginToMenu(MENU, self._edit_action)
self._delete_action = QAction(
QgsApplication.getThemeIcon('mActionDeleteSelectedFeatures.svg'),
- 'Delete Point', self.iface.mainWindow())
+ 'Delete Point', toolbar)
self._delete_action.setToolTip('Click a point to delete it')
self._delete_action.setCheckable(True)
self._delete_action.setEnabled(False)
self._delete_action.toggled.connect(lambda on: self._set_mode('delete', on))
- self.iface.addPluginToMenu(MENU, self._delete_action)
- def unload(self):
- self._set_active(False)
- self.iface.removeToolBarIcon(self._action)
- for action in (self._action, self._undo_action, self._edit_action,
- self._delete_action):
- self.iface.removePluginMenu(MENU, action)
- self._action = None
- self._undo_action = None
- self._edit_action = None
- self._delete_action = None
- self._history.clear()
+ toolbar.addAction(self._action)
+
+ def remove(self):
+ """Switch the tool off and take this session's buttons off the panel toolbar."""
+ self._action.setChecked(False)
+ self._toolbar.removeAction(self._action)
def _set_active(self, active):
if not active:
self._deactivate()
return
-
- canvas = self._find_profile_canvas()
- if canvas is None:
- self.iface.messageBar().pushMessage(
- MENU,
- 'Open the Elevation Profile panel first '
- '(View > Elevation Profile), then enable this tool.',
- level=Qgis.Warning,
- )
- if self._action is not None:
- self._action.setChecked(False)
- return
-
- self._canvas = canvas
- self._pick_tool = _ProfilePickTool(canvas, self._on_pick, self._clear_selection)
- canvas.setTool(self._pick_tool)
- self._attach_panel_actions(canvas)
+ self._pick_tool = _ProfilePickTool(self._canvas, self._on_pick, self._clear_selection)
+ self._canvas.setTool(self._pick_tool)
+ self._attach_panel_actions()
self.iface.messageBar().pushMessage(
MENU,
'Click on the elevation profile to place interpretation points.',
@@ -284,45 +277,32 @@ class ProfileInterpreterPlugin:
def _deactivate(self):
self._detach_panel_actions()
- if self._canvas is not None and self._pick_tool is not None:
+ if self._pick_tool is not None:
if QgsPlotToolPan is not None:
self._pan_tool = QgsPlotToolPan(self._canvas)
else:
self._pan_tool = QgsPlotTool(self._canvas, 'Inactive')
self._canvas.setTool(self._pan_tool)
self._pick_tool = None
- self._canvas = None
def _panel_actions(self):
- actions = (self._undo_action, self._edit_action, self._delete_action)
- return [a for a in actions if a is not None]
+ return [self._undo_action, self._edit_action, self._delete_action]
- def _attach_panel_actions(self, canvas):
- """Enable the mode actions and put all panel actions on the profile panel toolbar."""
+ def _attach_panel_actions(self):
+ """Enable the mode actions and put them next to Interpret on the panel toolbar."""
for action in (self._edit_action, self._delete_action):
- if action is not None:
- action.setEnabled(True)
- actions = self._panel_actions()
- if not actions:
- return
- toolbar = _find_panel_toolbar(canvas, self.iface.mainWindow())
- if toolbar is None:
- return
- for action in actions:
- toolbar.addAction(action)
- self._panel_toolbar = toolbar
+ action.setEnabled(True)
+ for action in self._panel_actions():
+ self._toolbar.addAction(action)
def _detach_panel_actions(self):
for action in (self._edit_action, self._delete_action):
- if action is not None:
- action.setChecked(False)
- action.setEnabled(False)
+ action.setChecked(False)
+ action.setEnabled(False)
self._clear_selection()
self._mode = 'add'
- if self._panel_toolbar is not None:
- for action in self._panel_actions():
- self._panel_toolbar.removeAction(action)
- self._panel_toolbar = None
+ for action in self._panel_actions():
+ self._toolbar.removeAction(action)
def _set_mode(self, mode, checked):
"""Switch between add, edit and delete; edit and delete are mutually exclusive."""
@@ -333,8 +313,7 @@ class ProfileInterpreterPlugin:
return
self._mode = mode
other = self._delete_action if mode == 'edit' else self._edit_action
- if other is not None:
- other.setChecked(False)
+ other.setChecked(False)
hint = ('Click a point to select it, then click its new position.'
if mode == 'edit' else 'Click a point to delete it.')
self.iface.messageBar().pushMessage(MENU, hint, level=Qgis.Info, duration=3)
@@ -347,13 +326,12 @@ class ProfileInterpreterPlugin:
self._selected = None
def _refresh_undo_state(self):
- if self._undo_action is not None:
- self._undo_action.setEnabled(bool(self._history))
+ self._undo_action.setEnabled(bool(self._history))
def _refresh_layer(self, layer):
layer.updateExtents()
layer.triggerRepaint()
- if self._canvas is not None and hasattr(self._canvas, 'refresh'):
+ if hasattr(self._canvas, 'refresh'):
self._canvas.refresh()
def _undo_last_point(self):
@@ -391,29 +369,6 @@ class ProfileInterpreterPlugin:
break
self._refresh_undo_state()
- def _find_profile_canvas(self):
- main_window = self.iface.mainWindow()
- canvases = main_window.findChildren(QgsElevationProfileCanvas)
- for canvas in canvases:
- if canvas.isVisible():
- return canvas
- return canvases[0] if canvases else None
-
- def _target_layer(self, crs):
- active = self.iface.activeLayer()
- if _is_suitable_target(active):
- return active
- return self._interpretation_layer(crs)
-
- def _search_layer(self):
- """The layer points are added to, without creating the fallback layer."""
- active = self.iface.activeLayer()
- if _is_suitable_target(active):
- return active
- if self._layer is not None and QgsProject.instance().mapLayer(self._layer.id()):
- return self._layer
- return None
-
def _click_to_point(self, event):
"""(distance, elevation, xy in canvas CRS) under the click, or None off the section."""
canvas = self._canvas
@@ -469,7 +424,7 @@ class ProfileInterpreterPlugin:
distance, elevation, xy = picked
canvas = self._canvas
- layer = self._target_layer(canvas.crs())
+ layer = self._plugin._target_layer(canvas.crs())
x, y = _project_xy(xy, canvas.crs(), layer.crs())
feature = QgsFeature(layer.fields())
feature.setGeometry(QgsGeometry(QgsPoint(x, y, elevation)))
@@ -515,7 +470,7 @@ class ProfileInterpreterPlugin:
self._move_selected(event)
def _select_for_edit(self, event):
- layer = self._search_layer()
+ layer = self._plugin._search_layer()
fid = self._find_point_at(event, layer) if layer is not None else None
if fid is None:
self.iface.messageBar().pushMessage(
@@ -573,7 +528,7 @@ class ProfileInterpreterPlugin:
)
def _delete_click(self, event):
- layer = self._search_layer()
+ layer = self._plugin._search_layer()
fid = self._find_point_at(event, layer) if layer is not None else None
if fid is None:
self.iface.messageBar().pushMessage(
@@ -590,17 +545,80 @@ class ProfileInterpreterPlugin:
self.iface.messageBar().pushMessage(
MENU, 'Failed to delete the point.', level=Qgis.Warning)
return
- self._history = [
- (lyr, f) for lyr, f in self._history
- if not (lyr.id() == layer.id() and f == fid)
- ]
- self._refresh_undo_state()
+ self._plugin._forget_point(layer, fid)
self._refresh_layer(layer)
QgsMessageLog.logMessage(
f'deleted point {fid} from "{layer.name()}"', MENU, level=Qgis.Info)
self.iface.messageBar().pushMessage(
MENU, 'Deleted interpretation point.', level=Qgis.Success, duration=2)
+
+class ProfileInterpreterPlugin:
+ def __init__(self, iface):
+ self.iface = iface
+ self._layer = None
+ self._sessions = []
+ self._watcher = None
+
+ def initGui(self):
+ self._watcher = _PanelWatcher(self._scan_panels)
+ self.iface.mainWindow().installEventFilter(self._watcher)
+ self._scan_panels()
+
+ def unload(self):
+ if self._watcher is not None:
+ self.iface.mainWindow().removeEventFilter(self._watcher)
+ self._watcher = None
+ for session in self._sessions:
+ session.remove()
+ self._sessions = []
+
+ def _scan_panels(self):
+ """Give every profile panel that has a toolbar and no session its own session."""
+ if self._watcher is None:
+ return
+ main_window = self.iface.mainWindow()
+ known = [session._canvas for session in self._sessions]
+ for canvas in main_window.findChildren(QgsElevationProfileCanvas):
+ if canvas in known:
+ continue
+ toolbar = _find_panel_toolbar(canvas, main_window)
+ if toolbar is None:
+ continue
+ session = _PanelSession(self, canvas, toolbar)
+ canvas.destroyed.connect(lambda *_, s=session: self._drop_session(s))
+ self._sessions.append(session)
+
+ def _drop_session(self, session):
+ """Forget a session whose panel was closed; its Qt objects are already gone."""
+ session._clear_selection()
+ if session in self._sessions:
+ self._sessions.remove(session)
+
+ def _forget_point(self, layer, fid):
+ """Remove a deleted point from every session's undo history."""
+ for session in self._sessions:
+ session._history = [
+ (lyr, f) for lyr, f in session._history
+ if not (lyr.id() == layer.id() and f == fid)
+ ]
+ session._refresh_undo_state()
+
+ def _target_layer(self, crs):
+ active = self.iface.activeLayer()
+ if _is_suitable_target(active):
+ return active
+ return self._interpretation_layer(crs)
+
+ def _search_layer(self):
+ """The layer points are added to, without creating the fallback layer."""
+ active = self.iface.activeLayer()
+ if _is_suitable_target(active):
+ return active
+ if self._layer is not None and QgsProject.instance().mapLayer(self._layer.id()):
+ return self._layer
+ return None
+
def _interpretation_layer(self, crs):
project = QgsProject.instance()
if self._layer is not None and project.mapLayer(self._layer.id()):
diff --git a/test/fakeqgis.py b/test/fakeqgis.py
@@ -35,6 +35,29 @@ class QMetaType:
QString = 3
+class QEvent:
+ class Type:
+ ChildAdded = 68
+ Show = 17
+
+ def __init__(self, event_type):
+ self._type = event_type
+
+ def type(self):
+ return self._type
+
+
+class QObject:
+ def __init__(self, parent=None):
+ pass
+
+
+class QTimer:
+ @staticmethod
+ def singleShot(msec, callback):
+ callback()
+
+
class QIcon:
def __init__(self, *args, **kwargs):
pass
@@ -48,7 +71,7 @@ class _FakeSignal:
self._callbacks.append(cb)
def emit(self, *args):
- for cb in self._callbacks:
+ for cb in list(self._callbacks):
cb(*args)
@@ -94,6 +117,9 @@ class QToolBar:
if action in self.actions_added:
self.actions_added.remove(action)
+ def actions(self):
+ return list(self.actions_added)
+
# ── qgis.core stubs ───────────────────────────────────────────────────────
@@ -468,12 +494,9 @@ class QgsPlotToolPan:
class QgsElevationProfileCanvas:
def __init__(self):
- self._visible = True
self.refresh_count = 0
self._tool = None
-
- def isVisible(self):
- return self._visible
+ self.destroyed = _FakeSignal()
def parentWidget(self):
return getattr(self, '_parent', None)
@@ -557,6 +580,9 @@ def install():
qgis_pyqt_qtcore.Qt = Qt
qgis_pyqt_qtcore.QPointF = QPointF
+ qgis_pyqt_qtcore.QEvent = QEvent
+ qgis_pyqt_qtcore.QObject = QObject
+ qgis_pyqt_qtcore.QTimer = QTimer
qgis_pyqt_qtcore.QMetaType = QMetaType
qgis_pyqt_qtgui.QIcon = QIcon
qgis_pyqt_qtwidgets.QAction = QAction
diff --git a/test/test_profile_interpreter.py b/test/test_profile_interpreter.py
@@ -23,6 +23,8 @@ from profile_interpreter.profile_interpreter import ( # noqa: E402
_project_xy,
_LEFT_BUTTON,
_RIGHT_BUTTON,
+ _PanelSession,
+ _PanelWatcher,
_ProfilePickTool,
ProfileInterpreterPlugin,
)
@@ -64,6 +66,7 @@ class _FakeCanvas:
self._tool = None
self._crs_val = crs
self.last_canvas_point = None
+ self.destroyed = fq._FakeSignal()
def canvasPointToPlotPoint(self, pt):
self.last_canvas_point = (pt.x(), pt.y())
@@ -91,10 +94,17 @@ class _FakeCanvas:
class _FakeMainWindow:
def __init__(self, canvases=None):
self._canvases = canvases or []
+ self.event_filters = []
def findChildren(self, cls):
return [c for c in self._canvases if isinstance(c, cls)]
+ def installEventFilter(self, obj):
+ self.event_filters.append(obj)
+
+ def removeEventFilter(self, obj):
+ self.event_filters.remove(obj)
+
class _FakeBar:
def pushMessage(self, *args, **kwargs):
@@ -143,13 +153,14 @@ class _FakeIface:
self.menu_actions.remove(action)
-def _make_plugin(active_layer=None, canvas=None):
+def _make_session(active_layer=None, canvas=None):
iface = _FakeIface(active_layer=active_layer)
plugin = ProfileInterpreterPlugin(iface)
- plugin._canvas = canvas or _FakeCanvas()
+ session = _PanelSession(plugin, canvas or _FakeCanvas(), fq.QToolBar())
+ plugin._sessions.append(session)
fq.QgsProject._instance = None
fq.QgsGeometry._next_interpolate_empty = False
- return plugin, iface
+ return session, iface
# ── _point_attributes ─────────────────────────────────────────────────────
@@ -270,36 +281,36 @@ class TestIsSuitableTarget(unittest.TestCase):
# ── _on_pick early exits ──────────────────────────────────────────────────
class TestOnPickEarlyExits(unittest.TestCase):
- def _pick(self, plugin, canvas=None):
+ def _pick(self, session, canvas=None):
"""Fire _on_pick and return feature count on memory layer."""
event = _FakeEvent()
- plugin._on_pick(event)
+ session._on_pick(event)
def test_nan_distance_skipped(self):
canvas = _FakeCanvas(distance=math.nan, elevation=5.0)
- plugin, _ = _make_plugin(canvas=canvas)
- self._pick(plugin)
- self.assertIsNone(plugin._layer)
+ session, _ = _make_session(canvas=canvas)
+ self._pick(session)
+ self.assertIsNone(session._plugin._layer)
def test_nan_elevation_skipped(self):
canvas = _FakeCanvas(distance=10.0, elevation=math.nan)
- plugin, _ = _make_plugin(canvas=canvas)
- self._pick(plugin)
- self.assertIsNone(plugin._layer)
+ session, _ = _make_session(canvas=canvas)
+ self._pick(session)
+ self.assertIsNone(session._plugin._layer)
def test_none_curve_skipped(self):
canvas = _FakeCanvas(curve=None)
- plugin, _ = _make_plugin(canvas=canvas)
- self._pick(plugin)
- self.assertIsNone(plugin._layer)
+ session, _ = _make_session(canvas=canvas)
+ self._pick(session)
+ self.assertIsNone(session._plugin._layer)
def test_empty_interpolated_geom_skipped(self):
canvas = _FakeCanvas()
- plugin, _ = _make_plugin(canvas=canvas)
+ session, _ = _make_session(canvas=canvas)
fq.QgsGeometry._next_interpolate_empty = True
- self._pick(plugin)
+ self._pick(session)
fq.QgsGeometry._next_interpolate_empty = False
- self.assertIsNone(plugin._layer)
+ self.assertIsNone(session._plugin._layer)
# ── _on_pick successful pick ──────────────────────────────────────────────
@@ -311,25 +322,25 @@ class TestOnPickSuccess(unittest.TestCase):
fq.QgsCoordinateTransform.reset()
def test_uses_memory_layer_when_no_suitable_active(self):
- plugin, _ = _make_plugin(active_layer=None)
- plugin._on_pick(_FakeEvent())
+ session, _ = _make_session(active_layer=None)
+ session._on_pick(_FakeEvent())
# memory layer was created and feature was added
- layer = plugin._layer
+ layer = session._plugin._layer
self.assertIsNotNone(layer)
self.assertEqual(len(layer._features), 1)
def test_feature_ids_do_not_collide(self):
- plugin, _ = _make_plugin()
- plugin._on_pick(_FakeEvent())
- plugin._on_pick(_FakeEvent())
- layer = plugin._layer
+ session, _ = _make_session()
+ session._on_pick(_FakeEvent())
+ session._on_pick(_FakeEvent())
+ layer = session._plugin._layer
ids = [f._attrs.get('id') for f in layer._features]
self.assertEqual(ids, [1, 2])
def test_memory_layer_attrs_set(self):
- plugin, _ = _make_plugin(active_layer=None)
- plugin._on_pick(_FakeEvent())
- feature = plugin._layer._features[0]
+ session, _ = _make_session(active_layer=None)
+ session._on_pick(_FakeEvent())
+ feature = session._plugin._layer._features[0]
self.assertEqual(feature._attrs.get('id'), 1)
self.assertIn('distance', feature._attrs)
self.assertIn('elevation', feature._attrs)
@@ -338,18 +349,18 @@ class TestOnPickSuccess(unittest.TestCase):
active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory')
active._fields_list.append(fq.QgsField('distance'))
active._fields_list.append(fq.QgsField('elevation'))
- plugin, _ = _make_plugin(active_layer=active)
- plugin._on_pick(_FakeEvent())
+ session, _ = _make_session(active_layer=active)
+ session._on_pick(_FakeEvent())
# feature went to active layer, not memory layer
self.assertEqual(len(active._features), 1)
- self.assertIsNone(plugin._layer)
+ self.assertIsNone(session._plugin._layer)
def test_only_matching_fields_written_to_active_layer(self):
active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory')
active._fields_list.append(fq.QgsField('elevation'))
# 'id' and 'distance' fields absent
- plugin, _ = _make_plugin(active_layer=active)
- plugin._on_pick(_FakeEvent())
+ session, _ = _make_session(active_layer=active)
+ session._on_pick(_FakeEvent())
feature = active._features[0]
self.assertIn('elevation', feature._attrs)
self.assertNotIn('id', feature._attrs)
@@ -357,22 +368,22 @@ class TestOnPickSuccess(unittest.TestCase):
def test_canvas_refresh_called_once(self):
canvas = _FakeCanvas()
- plugin, _ = _make_plugin(canvas=canvas)
- plugin._on_pick(_FakeEvent())
+ session, _ = _make_session(canvas=canvas)
+ session._on_pick(_FakeEvent())
self.assertEqual(canvas.refresh_count, 1)
def test_canvas_refresh_not_called_on_early_exit(self):
canvas = _FakeCanvas(curve=None)
- plugin, _ = _make_plugin(canvas=canvas)
- plugin._on_pick(_FakeEvent())
+ session, _ = _make_session(canvas=canvas)
+ session._on_pick(_FakeEvent())
self.assertEqual(canvas.refresh_count, 0)
def test_active_layer_different_crs_transforms_geometry(self):
active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory')
active._crs = fq._FakeCrs('EPSG:32632')
canvas = _FakeCanvas(crs=fq._FakeCrs('EPSG:4326'))
- plugin, _ = _make_plugin(active_layer=active, canvas=canvas)
- plugin._on_pick(_FakeEvent())
+ session, _ = _make_session(active_layer=active, canvas=canvas)
+ session._on_pick(_FakeEvent())
self.assertEqual(len(fq.QgsCoordinateTransform._calls), 1)
feature = active._features[0]
self.assertAlmostEqual(feature._geom._obj._x, 1100.0)
@@ -382,8 +393,8 @@ class TestOnPickSuccess(unittest.TestCase):
active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory')
active._crs = fq._FakeCrs('EPSG:4326')
canvas = _FakeCanvas(crs=fq._FakeCrs('EPSG:4326'))
- plugin, _ = _make_plugin(active_layer=active, canvas=canvas)
- plugin._on_pick(_FakeEvent())
+ session, _ = _make_session(active_layer=active, canvas=canvas)
+ session._on_pick(_FakeEvent())
self.assertEqual(fq.QgsCoordinateTransform._calls, [])
feature = active._features[0]
self.assertAlmostEqual(feature._geom._obj._x, 100.0)
@@ -410,35 +421,6 @@ class TestPickTool(unittest.TestCase):
self.assertEqual(calls, [])
-# ── _find_profile_canvas ──────────────────────────────────────────────────
-
-class TestFindProfileCanvas(unittest.TestCase):
- def test_prefers_visible_canvas(self):
- hidden = fq.QgsElevationProfileCanvas()
- hidden._visible = False
- visible = fq.QgsElevationProfileCanvas()
- visible._visible = True
-
- iface = _FakeIface(canvases=[hidden, visible])
- plugin = ProfileInterpreterPlugin(iface)
- self.assertIs(plugin._find_profile_canvas(), visible)
-
- def test_falls_back_to_first_when_all_hidden(self):
- c1 = fq.QgsElevationProfileCanvas()
- c1._visible = False
- c2 = fq.QgsElevationProfileCanvas()
- c2._visible = False
-
- iface = _FakeIface(canvases=[c1, c2])
- plugin = ProfileInterpreterPlugin(iface)
- self.assertIs(plugin._find_profile_canvas(), c1)
-
- def test_returns_none_when_no_canvases(self):
- iface = _FakeIface(canvases=[])
- plugin = ProfileInterpreterPlugin(iface)
- self.assertIsNone(plugin._find_profile_canvas())
-
-
# ── _interpretation_layer field names ────────────────────────────────────
class TestInterpretationLayerFields(unittest.TestCase):
@@ -447,9 +429,9 @@ class TestInterpretationLayerFields(unittest.TestCase):
fq.QgsGeometry._next_interpolate_empty = False
def test_fallback_layer_has_exactly_id_distance_elevation(self):
- plugin, _ = _make_plugin(active_layer=None)
- plugin._on_pick(_FakeEvent())
- layer = plugin._layer
+ session, _ = _make_session(active_layer=None)
+ session._on_pick(_FakeEvent())
+ layer = session._plugin._layer
self.assertIsNotNone(layer)
field_names = {f.name() for f in layer.fields()}
self.assertEqual(field_names, {'id', 'distance', 'elevation'})
@@ -466,16 +448,16 @@ class TestOnPickAddFeaturesFailure(unittest.TestCase):
fq._FakeDataProvider._fail_next = False
def test_failure_shows_warning_and_adds_no_features(self):
- plugin, iface = _make_plugin(active_layer=None)
+ session, iface = _make_session(active_layer=None)
bar = _TrackingBar()
iface._bar = bar
fq._FakeDataProvider._fail_next = True
- canvas = plugin._canvas
- plugin._on_pick(_FakeEvent())
+ canvas = session._canvas
+ session._on_pick(_FakeEvent())
# feature was not committed
- self.assertEqual(len(plugin._layer._features), 0)
+ self.assertEqual(len(session._plugin._layer._features), 0)
# a warning was pushed
self.assertTrue(any(level == fq.Qgis.Warning for _, _, level in bar.messages))
# canvas was not refreshed
@@ -487,33 +469,29 @@ class TestOnPickAddFeaturesFailure(unittest.TestCase):
class TestDeactivate(unittest.TestCase):
def _make_active_plugin(self):
canvas = _FakeCanvas()
- iface = _FakeIface()
- plugin = ProfileInterpreterPlugin(iface)
- plugin._canvas = canvas
+ session, _ = _make_session(canvas=canvas)
pick_tool = fq.QgsPlotTool(canvas, 'test')
- plugin._pick_tool = pick_tool
+ session._pick_tool = pick_tool
canvas._tool = pick_tool
- return plugin, canvas, pick_tool
+ return session, canvas, pick_tool
def test_pick_tool_replaced_when_pan_available(self):
- plugin, canvas, pick_tool = self._make_active_plugin()
- plugin._deactivate()
+ session, canvas, pick_tool = self._make_active_plugin()
+ session._deactivate()
self.assertIsNot(canvas._tool, pick_tool)
- self.assertIsNone(plugin._pick_tool)
- self.assertIsNone(plugin._canvas)
+ self.assertIsNone(session._pick_tool)
def test_pick_tool_replaced_when_pan_none(self):
import profile_interpreter.profile_interpreter as pi_module
- plugin, canvas, pick_tool = self._make_active_plugin()
+ session, canvas, pick_tool = self._make_active_plugin()
original = pi_module.QgsPlotToolPan
pi_module.QgsPlotToolPan = None
try:
- plugin._deactivate()
+ session._deactivate()
finally:
pi_module.QgsPlotToolPan = original
self.assertIsNot(canvas._tool, pick_tool)
- self.assertIsNone(plugin._pick_tool)
- self.assertIsNone(plugin._canvas)
+ self.assertIsNone(session._pick_tool)
# ── _can_delete ───────────────────────────────────────────────────────────
@@ -562,6 +540,28 @@ class _FakeWidget:
return [c for c in self._children if isinstance(c, cls)]
+def _panel(main_window, toolbar=None):
+ """Profile canvas inside a panel widget holding toolbar (or no toolbar)."""
+ canvas = fq.QgsElevationProfileCanvas()
+ children = [toolbar] if toolbar is not None else []
+ canvas._parent = _FakeWidget(parent=main_window, children=children)
+ main_window._canvases.append(canvas)
+ return canvas
+
+
+def _activated_session():
+ main_window = _FakeMainWindow()
+ toolbar = fq.QToolBar()
+ _panel(main_window, toolbar)
+ iface = _FakeIface()
+ iface._window = main_window
+ plugin = ProfileInterpreterPlugin(iface)
+ plugin.initGui()
+ session = plugin._sessions[0]
+ session._action.setChecked(True)
+ return session, iface, toolbar
+
+
class TestFindPanelToolbar(unittest.TestCase):
def test_finds_toolbar_in_panel(self):
main_window = _FakeWidget()
@@ -593,67 +593,164 @@ class TestFindPanelToolbar(unittest.TestCase):
self.assertIsNone(_find_panel_toolbar(canvas, _FakeWidget()))
-# ── undo action lifecycle ─────────────────────────────────────────────────
+# ── per-panel sessions ────────────────────────────────────────────────────
-class TestUndoAction(unittest.TestCase):
+class TestPanelSessions(unittest.TestCase):
def setUp(self):
fq.QgsProject._instance = None
fq.QgsGeometry._next_interpolate_empty = False
+ fq.QgsGeometry._lateral_distance = 0.0
fq._FakeDataProvider._fail_next = False
fq._FakeDataProvider._fail_next_delete = False
- def _activated_plugin(self):
- """Plugin with initGui() run and the pick tool active on a panel canvas."""
- canvas = fq.QgsElevationProfileCanvas()
- toolbar = fq.QToolBar()
- main_window = _FakeMainWindow([canvas])
- panel = _FakeWidget(parent=main_window, children=[toolbar])
- canvas._parent = panel
-
+ def _plugin(self, panels=2):
+ """Plugin after initGui() with that many panels, each with its own toolbar."""
+ main_window = _FakeMainWindow()
+ toolbars = [fq.QToolBar() for _ in range(panels)]
+ canvases = [_panel(main_window, toolbar) for toolbar in toolbars]
iface = _FakeIface()
iface._window = main_window
plugin = ProfileInterpreterPlugin(iface)
plugin.initGui()
- plugin._set_active(True)
- return plugin, iface, toolbar
+ return plugin, iface, canvases, toolbars
+
+ def test_init_adds_nothing_to_plugins_toolbar_or_menu(self):
+ plugin, iface, _, _ = self._plugin(panels=0)
+ self.assertEqual(iface.toolbar_actions, [])
+ self.assertEqual(iface.menu_actions, [])
+ self.assertEqual(iface._window.event_filters, [plugin._watcher])
+
+ def test_each_panel_gets_its_own_interpret_action(self):
+ plugin, _, canvases, toolbars = self._plugin()
+ a, b = plugin._sessions
+ self.assertEqual((a._canvas, b._canvas), tuple(canvases))
+ self.assertEqual(toolbars[0].actions_added, [a._action])
+ self.assertEqual(toolbars[1].actions_added, [b._action])
+ self.assertIsNot(a._action, b._action)
+
+ def test_rescan_creates_no_duplicates(self):
+ plugin, _, _, toolbars = self._plugin()
+ plugin._scan_panels()
+ self.assertEqual(len(plugin._sessions), 2)
+ self.assertEqual([len(t.actions_added) for t in toolbars], [1, 1])
+
+ def test_panel_opened_later_gets_a_session(self):
+ plugin, iface, _, _ = self._plugin(panels=0)
+ toolbar = fq.QToolBar()
+ canvas = _panel(iface._window, toolbar)
+ plugin._watcher.eventFilter(iface._window, fq.QEvent(fq.QEvent.Type.Show))
+ self.assertEqual(plugin._sessions, [])
+ handled = plugin._watcher.eventFilter(
+ iface._window, fq.QEvent(fq.QEvent.Type.ChildAdded))
+ self.assertFalse(handled)
+ self.assertEqual([s._canvas for s in plugin._sessions], [canvas])
+ self.assertEqual(toolbar.actions_added, [plugin._sessions[0]._action])
+
+ def test_panel_without_toolbar_is_skipped(self):
+ plugin, iface, _, _ = self._plugin(panels=0)
+ _panel(iface._window)
+ plugin._scan_panels()
+ self.assertEqual(plugin._sessions, [])
+
+ def test_panels_switch_on_independently(self):
+ plugin, _, canvases, toolbars = self._plugin()
+ a, b = plugin._sessions
+ a._action.setChecked(True)
+ self.assertIs(canvases[0]._tool, a._pick_tool)
+ self.assertIsNone(canvases[1]._tool)
+ self.assertEqual(toolbars[0].actions_added,
+ [a._action, a._undo_action, a._edit_action, a._delete_action])
+ self.assertEqual(toolbars[1].actions_added, [b._action])
+ b._action.setChecked(True)
+ self.assertIs(canvases[0]._tool, a._pick_tool)
+ self.assertIs(canvases[1]._tool, b._pick_tool)
+
+ def test_undo_histories_are_separate(self):
+ plugin, _, _, _ = self._plugin()
+ a, b = plugin._sessions
+ a._on_pick(_FakeEvent())
+ a._on_pick(_FakeEvent())
+ b._on_pick(_FakeEvent())
+ layer = plugin._layer
+ b._undo_last_point()
+ self.assertEqual([f._attrs['id'] for f in layer._features], [1, 2])
+ self.assertEqual(len(a._history), 2)
+ self.assertFalse(b._undo_action.isEnabled())
+
+ def test_delete_from_other_panel_prunes_history(self):
+ plugin, _, _, _ = self._plugin()
+ a, b = plugin._sessions
+ a._on_pick(_FakeEvent())
+ layer = plugin._layer
+ layer._features[0].setGeometry(fq.QgsGeometry(fq.QgsPoint(30.0, 0.0, 5.0)))
+ b._action.setChecked(True)
+ b._delete_action.setChecked(True)
+ b._on_pick(_FakeEvent(x=30.0, y=5.0))
+ self.assertEqual(layer._features, [])
+ self.assertEqual(a._history, [])
+ self.assertFalse(a._undo_action.isEnabled())
+
+ def test_closed_panel_drops_its_session_only(self):
+ plugin, _, canvases, _ = self._plugin()
+ a, b = plugin._sessions
+ a._action.setChecked(True)
+ b._action.setChecked(True)
+
+ def deleted(*args):
+ raise RuntimeError('wrapped C/C++ object has been deleted')
+ canvases[0].setTool = deleted
+ canvases[0].destroyed.emit(canvases[0])
+ self.assertEqual(plugin._sessions, [b])
+ self.assertIs(canvases[1]._tool, b._pick_tool)
+ plugin.unload()
- def test_added_to_plugin_menu_on_init(self):
- iface = _FakeIface()
- plugin = ProfileInterpreterPlugin(iface)
- plugin.initGui()
- self.assertIn(plugin._undo_action, iface.menu_actions)
+ def test_unload_restores_pan_and_removes_everything(self):
+ plugin, iface, canvases, toolbars = self._plugin()
+ plugin._sessions[0]._action.setChecked(True)
+ plugin.unload()
+ self.assertIsInstance(canvases[0]._tool, fq.QgsPlotToolPan)
+ self.assertIsNone(canvases[1]._tool)
+ self.assertEqual([t.actions_added for t in toolbars], [[], []])
+ self.assertEqual(iface._window.event_filters, [])
+ self.assertEqual(plugin._sessions, [])
+ plugin._scan_panels()
+ self.assertEqual(plugin._sessions, [])
+
+ def test_watcher_ignores_events_while_pending(self):
+ calls = []
+ watcher = _PanelWatcher(lambda: calls.append(1))
+ watcher._pending = True
+ watcher.eventFilter(None, fq.QEvent(fq.QEvent.Type.ChildAdded))
+ self.assertEqual(calls, [])
+
+
+# ── undo action lifecycle ─────────────────────────────────────────────────
+
+class TestUndoAction(unittest.TestCase):
+ def setUp(self):
+ fq.QgsProject._instance = None
+ fq.QgsGeometry._next_interpolate_empty = False
+ fq._FakeDataProvider._fail_next = False
+ fq._FakeDataProvider._fail_next_delete = False
+
+ def _activated(self):
+ """Session of a panel found by initGui(), with the pick tool on."""
+ return _activated_session()
def test_disabled_until_a_point_is_placed(self):
- plugin, _, _ = self._activated_plugin()
- self.assertFalse(plugin._undo_action.isEnabled())
- plugin._on_pick(_FakeEvent())
- self.assertTrue(plugin._undo_action.isEnabled())
+ session, _, _ = self._activated()
+ self.assertFalse(session._undo_action.isEnabled())
+ session._on_pick(_FakeEvent())
+ self.assertTrue(session._undo_action.isEnabled())
def test_appears_on_panel_toolbar_while_active(self):
- plugin, _, toolbar = self._activated_plugin()
- self.assertIn(plugin._undo_action, toolbar.actions_added)
+ session, _, toolbar = self._activated()
+ self.assertIn(session._undo_action, toolbar.actions_added)
def test_removed_from_panel_toolbar_when_deactivated(self):
- plugin, _, toolbar = self._activated_plugin()
- plugin._set_active(False)
- self.assertNotIn(plugin._undo_action, toolbar.actions_added)
-
- def test_missing_panel_toolbar_is_tolerated(self):
- canvas = fq.QgsElevationProfileCanvas()
- main_window = _FakeMainWindow([canvas])
- canvas._parent = _FakeWidget(parent=main_window)
- iface = _FakeIface()
- iface._window = main_window
- plugin = ProfileInterpreterPlugin(iface)
- plugin.initGui()
- plugin._set_active(True)
- self.assertIsNone(plugin._panel_toolbar)
-
- def test_menu_entry_removed_on_unload(self):
- plugin, iface, _ = self._activated_plugin()
- undo_action = plugin._undo_action
- plugin.unload()
- self.assertNotIn(undo_action, iface.menu_actions)
+ session, _, toolbar = self._activated()
+ session._action.setChecked(False)
+ self.assertNotIn(session._undo_action, toolbar.actions_added)
# ── _undo_last_point ──────────────────────────────────────────────────────
@@ -666,80 +763,80 @@ class TestUndoLastPoint(unittest.TestCase):
fq._FakeDataProvider._fail_next_delete = False
def test_removes_last_placed_feature(self):
- plugin, _ = _make_plugin()
- plugin._on_pick(_FakeEvent())
- plugin._on_pick(_FakeEvent())
- layer = plugin._layer
- plugin._undo_last_point()
+ session, _ = _make_session()
+ session._on_pick(_FakeEvent())
+ session._on_pick(_FakeEvent())
+ layer = session._plugin._layer
+ session._undo_last_point()
self.assertEqual([f._attrs['id'] for f in layer._features], [1])
def test_repeated_undo_empties_the_layer(self):
- plugin, _ = _make_plugin()
- plugin._on_pick(_FakeEvent())
- plugin._on_pick(_FakeEvent())
- layer = plugin._layer
- plugin._undo_last_point()
- plugin._undo_last_point()
+ session, _ = _make_session()
+ session._on_pick(_FakeEvent())
+ session._on_pick(_FakeEvent())
+ layer = session._plugin._layer
+ session._undo_last_point()
+ session._undo_last_point()
self.assertEqual(layer._features, [])
- self.assertEqual(plugin._history, [])
+ self.assertEqual(session._history, [])
def test_undo_with_empty_history_is_a_no_op(self):
- plugin, _ = _make_plugin()
- plugin._undo_last_point()
- self.assertEqual(plugin._history, [])
+ session, _ = _make_session()
+ session._undo_last_point()
+ self.assertEqual(session._history, [])
def test_ids_reused_after_undo(self):
- plugin, _ = _make_plugin()
- plugin._on_pick(_FakeEvent())
- plugin._on_pick(_FakeEvent())
- plugin._undo_last_point()
- plugin._on_pick(_FakeEvent())
- self.assertEqual([f._attrs['id'] for f in plugin._layer._features], [1, 2])
+ session, _ = _make_session()
+ session._on_pick(_FakeEvent())
+ session._on_pick(_FakeEvent())
+ session._undo_last_point()
+ session._on_pick(_FakeEvent())
+ self.assertEqual([f._attrs['id'] for f in session._plugin._layer._features], [1, 2])
def test_canvas_refreshed_after_undo(self):
canvas = _FakeCanvas()
- plugin, _ = _make_plugin(canvas=canvas)
- plugin._on_pick(_FakeEvent())
- plugin._undo_last_point()
+ session, _ = _make_session(canvas=canvas)
+ session._on_pick(_FakeEvent())
+ session._undo_last_point()
self.assertEqual(canvas.refresh_count, 2)
def test_layer_removed_from_project_is_skipped(self):
- plugin, _ = _make_plugin()
- plugin._on_pick(_FakeEvent())
- plugin._on_pick(_FakeEvent())
- layer = plugin._layer
+ session, _ = _make_session()
+ session._on_pick(_FakeEvent())
+ session._on_pick(_FakeEvent())
+ layer = session._plugin._layer
fq.QgsProject.instance()._layers.pop(layer.id())
- plugin._undo_last_point()
+ session._undo_last_point()
# both history entries pointed at the vanished layer; nothing deleted
self.assertEqual(len(layer._features), 2)
- self.assertEqual(plugin._history, [])
+ self.assertEqual(session._history, [])
def test_read_only_layer_warns_and_keeps_feature(self):
- plugin, iface = _make_plugin()
+ session, iface = _make_session()
bar = _TrackingBar()
iface._bar = bar
- plugin._on_pick(_FakeEvent())
- layer = plugin._layer
+ session._on_pick(_FakeEvent())
+ layer = session._plugin._layer
layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures
- plugin._undo_last_point()
+ session._undo_last_point()
self.assertEqual(len(layer._features), 1)
self.assertTrue(any(level == fq.Qgis.Warning for _, _, level in bar.messages))
def test_delete_failure_warns(self):
- plugin, iface = _make_plugin()
+ session, iface = _make_session()
bar = _TrackingBar()
iface._bar = bar
- plugin._on_pick(_FakeEvent())
+ session._on_pick(_FakeEvent())
fq._FakeDataProvider._fail_next_delete = True
- plugin._undo_last_point()
- self.assertEqual(len(plugin._layer._features), 1)
+ session._undo_last_point()
+ self.assertEqual(len(session._plugin._layer._features), 1)
self.assertTrue(any(level == fq.Qgis.Warning for _, _, level in bar.messages))
def test_failed_pick_adds_no_history(self):
- plugin, _ = _make_plugin()
+ session, _ = _make_session()
fq._FakeDataProvider._fail_next = True
- plugin._on_pick(_FakeEvent())
- self.assertEqual(plugin._history, [])
+ session._on_pick(_FakeEvent())
+ self.assertEqual(session._history, [])
# ── _nearest_hit / _can_move ──────────────────────────────────────────────
@@ -800,157 +897,135 @@ class _ModeTestCase(unittest.TestCase):
fq._FakeDataProvider._fail_next_delete = False
fq._FakeDataProvider._fail_next_change = False
- def _plugin(self, layer=None, canvas=None):
- plugin, iface = _make_plugin(active_layer=layer, canvas=canvas)
+ def _session(self, layer=None, canvas=None):
+ session, iface = _make_session(active_layer=layer, canvas=canvas)
if layer is not None:
fq.QgsProject.instance().addMapLayer(layer)
iface._bar = _TrackingBar()
- plugin.initGui()
- return plugin, iface
+ return session, iface
class TestModeActions(_ModeTestCase):
- def _activated_plugin(self):
- canvas = fq.QgsElevationProfileCanvas()
- toolbar = fq.QToolBar()
- main_window = _FakeMainWindow([canvas])
- canvas._parent = _FakeWidget(parent=main_window, children=[toolbar])
- iface = _FakeIface()
- iface._window = main_window
- plugin = ProfileInterpreterPlugin(iface)
- plugin.initGui()
- plugin._set_active(True)
- return plugin, iface, toolbar
-
- def test_added_to_plugin_menu_disabled(self):
- plugin, iface = self._plugin()
- for action in (plugin._edit_action, plugin._delete_action):
- self.assertIn(action, iface.menu_actions)
- self.assertFalse(action.isEnabled())
+ def _activated(self):
+ return _activated_session()
def test_attached_next_to_undo_while_active(self):
- plugin, _, toolbar = self._activated_plugin()
+ session, _, toolbar = self._activated()
self.assertEqual(
toolbar.actions_added,
- [plugin._undo_action, plugin._edit_action, plugin._delete_action])
- self.assertTrue(plugin._edit_action.isEnabled())
- self.assertTrue(plugin._delete_action.isEnabled())
+ [session._action, session._undo_action, session._edit_action,
+ session._delete_action])
+ self.assertTrue(session._edit_action.isEnabled())
+ self.assertTrue(session._delete_action.isEnabled())
def test_detached_and_reset_on_deactivate(self):
- plugin, _, toolbar = self._activated_plugin()
- plugin._edit_action.setChecked(True)
- plugin._set_active(False)
- self.assertEqual(toolbar.actions_added, [])
- self.assertFalse(plugin._edit_action.isChecked())
- self.assertFalse(plugin._edit_action.isEnabled())
- self.assertEqual(plugin._mode, 'add')
+ session, _, toolbar = self._activated()
+ session._edit_action.setChecked(True)
+ session._action.setChecked(False)
+ self.assertEqual(toolbar.actions_added, [session._action])
+ self.assertFalse(session._edit_action.isChecked())
+ self.assertFalse(session._edit_action.isEnabled())
+ self.assertEqual(session._mode, 'add')
def test_edit_and_delete_are_exclusive(self):
- plugin, _ = self._plugin()
- plugin._edit_action.setChecked(True)
- self.assertEqual(plugin._mode, 'edit')
- plugin._delete_action.setChecked(True)
- self.assertEqual(plugin._mode, 'delete')
- self.assertFalse(plugin._edit_action.isChecked())
- plugin._delete_action.setChecked(False)
- self.assertEqual(plugin._mode, 'add')
-
- def test_menu_entries_removed_on_unload(self):
- plugin, iface, _ = self._activated_plugin()
- edit, delete = plugin._edit_action, plugin._delete_action
- plugin.unload()
- self.assertNotIn(edit, iface.menu_actions)
- self.assertNotIn(delete, iface.menu_actions)
+ session, _ = self._session()
+ session._edit_action.setChecked(True)
+ self.assertEqual(session._mode, 'edit')
+ session._delete_action.setChecked(True)
+ self.assertEqual(session._mode, 'delete')
+ self.assertFalse(session._edit_action.isChecked())
+ session._delete_action.setChecked(False)
+ self.assertEqual(session._mode, 'add')
class TestEditPoint(_ModeTestCase):
def test_select_then_move_updates_geometry_and_attrs(self):
layer = _layer_with_points([(30.0, 5.0), (60.0, 5.0)])
canvas = _FakeCanvas(distance=12.0, elevation=7.0)
- plugin, _ = self._plugin(layer, canvas)
- plugin._edit_action.setChecked(True)
+ session, _ = self._session(layer, canvas)
+ session._edit_action.setChecked(True)
- plugin._on_pick(_FakeEvent(x=31.0, y=6.0))
- self.assertEqual(plugin._selected, (layer, 1))
+ session._on_pick(_FakeEvent(x=31.0, y=6.0))
+ self.assertEqual(session._selected, (layer, 1))
self.assertEqual(layer.selected_ids, [1])
- plugin._on_pick(_FakeEvent(x=200.0, y=200.0))
+ session._on_pick(_FakeEvent(x=200.0, y=200.0))
moved = layer._features[0]
self.assertEqual(_xyz(moved), (100.0, 200.0, 7.0))
self.assertEqual(moved._attrs['distance'], 12.0)
self.assertEqual(moved._attrs['elevation'], 7.0)
self.assertNotIn('id', moved._attrs)
self.assertEqual(_xyz(layer._features[1]), (60.0, 0.0, 5.0))
- self.assertIsNone(plugin._selected)
+ self.assertIsNone(session._selected)
self.assertEqual(layer.selected_ids, [])
self.assertEqual(canvas.refresh_count, 1)
def test_miss_with_nothing_selected_changes_nothing(self):
layer = _layer_with_points([(30.0, 5.0)])
- plugin, iface = self._plugin(layer)
- plugin._edit_action.setChecked(True)
- plugin._on_pick(_FakeEvent(x=80.0, y=80.0))
- self.assertIsNone(plugin._selected)
+ session, iface = self._session(layer)
+ session._edit_action.setChecked(True)
+ session._on_pick(_FakeEvent(x=80.0, y=80.0))
+ self.assertIsNone(session._selected)
self.assertEqual(_xyz(layer._features[0]), (30.0, 0.0, 5.0))
self.assertIn('No point near the click.', [m for _, m, _ in iface._bar.messages])
def test_right_click_cancels_selection(self):
layer = _layer_with_points([(30.0, 5.0)])
- plugin, _ = self._plugin(layer)
- plugin._edit_action.setChecked(True)
- plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
- tool = _ProfilePickTool(plugin._canvas, plugin._on_pick, plugin._clear_selection)
+ session, _ = self._session(layer)
+ session._edit_action.setChecked(True)
+ session._on_pick(_FakeEvent(x=30.0, y=5.0))
+ tool = _ProfilePickTool(session._canvas, session._on_pick, session._clear_selection)
tool.plotReleaseEvent(_FakeEvent(button=_RIGHT_BUTTON))
- self.assertIsNone(plugin._selected)
+ self.assertIsNone(session._selected)
self.assertEqual(layer.selected_ids, [])
def test_toggling_edit_off_cancels_selection(self):
layer = _layer_with_points([(30.0, 5.0)])
- plugin, _ = self._plugin(layer)
- plugin._edit_action.setChecked(True)
- plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
- plugin._edit_action.setChecked(False)
- self.assertIsNone(plugin._selected)
- self.assertEqual(plugin._mode, 'add')
+ session, _ = self._session(layer)
+ session._edit_action.setChecked(True)
+ session._on_pick(_FakeEvent(x=30.0, y=5.0))
+ session._edit_action.setChecked(False)
+ self.assertIsNone(session._selected)
+ self.assertEqual(session._mode, 'add')
def test_layer_without_change_geometries_warns(self):
layer = _layer_with_points([(30.0, 5.0)])
layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures
- plugin, iface = self._plugin(layer)
- plugin._edit_action.setChecked(True)
- plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
- self.assertIsNone(plugin._selected)
+ session, iface = self._session(layer)
+ session._edit_action.setChecked(True)
+ session._on_pick(_FakeEvent(x=30.0, y=5.0))
+ self.assertIsNone(session._selected)
self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages))
def test_change_failure_warns_and_keeps_selection(self):
layer = _layer_with_points([(30.0, 5.0)])
- plugin, iface = self._plugin(layer)
- plugin._edit_action.setChecked(True)
- plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
+ session, iface = self._session(layer)
+ session._edit_action.setChecked(True)
+ session._on_pick(_FakeEvent(x=30.0, y=5.0))
fq._FakeDataProvider._fail_next_change = True
- plugin._on_pick(_FakeEvent(x=0.0, y=0.0))
+ session._on_pick(_FakeEvent(x=0.0, y=0.0))
self.assertEqual(_xyz(layer._features[0]), (30.0, 0.0, 5.0))
- self.assertEqual(plugin._selected, (layer, 1))
+ self.assertEqual(session._selected, (layer, 1))
self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages))
def test_points_beyond_profile_tolerance_ignored(self):
layer = _layer_with_points([(30.0, 5.0)])
canvas = _FakeCanvas()
canvas.tolerance = lambda: 5.0
- plugin, _ = self._plugin(layer, canvas)
- plugin._edit_action.setChecked(True)
+ session, _ = self._session(layer, canvas)
+ session._edit_action.setChecked(True)
fq.QgsGeometry._lateral_distance = 6.0
- plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
- self.assertIsNone(plugin._selected)
+ session._on_pick(_FakeEvent(x=30.0, y=5.0))
+ self.assertIsNone(session._selected)
fq.QgsGeometry._lateral_distance = 3.0
- plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
- self.assertEqual(plugin._selected, (layer, 1))
+ session._on_pick(_FakeEvent(x=30.0, y=5.0))
+ self.assertEqual(session._selected, (layer, 1))
def test_no_fallback_layer_created(self):
- plugin, _ = self._plugin()
- plugin._edit_action.setChecked(True)
- plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
- self.assertIsNone(plugin._layer)
+ session, _ = self._session()
+ session._edit_action.setChecked(True)
+ session._on_pick(_FakeEvent(x=30.0, y=5.0))
+ self.assertIsNone(session._plugin._layer)
class TestSnapping(_ModeTestCase):
@@ -964,33 +1039,33 @@ class TestSnapping(_ModeTestCase):
def test_add_converts_snapped_point(self):
canvas = _FakeCanvas()
- plugin, _ = self._plugin(canvas=canvas)
- plugin._on_pick(_FakeEvent(x=1.0, y=2.0, snapped=(3.0, 4.0)))
+ session, _ = self._session(canvas=canvas)
+ session._on_pick(_FakeEvent(x=1.0, y=2.0, snapped=(3.0, 4.0)))
self.assertEqual(canvas.last_canvas_point, (3.0, 4.0))
- self.assertEqual(len(plugin._layer._features), 1)
+ self.assertEqual(len(session._plugin._layer._features), 1)
def test_move_converts_snapped_point(self):
layer = _layer_with_points([(30.0, 5.0)])
canvas = _FakeCanvas()
- plugin, _ = self._plugin(layer, canvas)
- plugin._edit_action.setChecked(True)
- plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
- plugin._on_pick(_FakeEvent(x=1.0, y=2.0, snapped=(3.0, 4.0)))
+ session, _ = self._session(layer, canvas)
+ session._edit_action.setChecked(True)
+ session._on_pick(_FakeEvent(x=30.0, y=5.0))
+ session._on_pick(_FakeEvent(x=1.0, y=2.0, snapped=(3.0, 4.0)))
self.assertEqual(canvas.last_canvas_point, (3.0, 4.0))
- self.assertIsNone(plugin._selected)
+ self.assertIsNone(session._selected)
def test_select_for_edit_uses_raw_cursor(self):
layer = _layer_with_points([(30.0, 5.0)])
- plugin, _ = self._plugin(layer)
- plugin._edit_action.setChecked(True)
- plugin._on_pick(_FakeEvent(x=30.0, y=5.0, snapped=(200.0, 200.0)))
- self.assertEqual(plugin._selected, (layer, 1))
+ session, _ = self._session(layer)
+ session._edit_action.setChecked(True)
+ session._on_pick(_FakeEvent(x=30.0, y=5.0, snapped=(200.0, 200.0)))
+ self.assertEqual(session._selected, (layer, 1))
def test_delete_uses_raw_cursor(self):
layer = _layer_with_points([(30.0, 5.0)])
- plugin, _ = self._plugin(layer)
- plugin._delete_action.setChecked(True)
- plugin._on_pick(_FakeEvent(x=30.0, y=5.0, snapped=(200.0, 200.0)))
+ session, _ = self._session(layer)
+ session._delete_action.setChecked(True)
+ session._on_pick(_FakeEvent(x=30.0, y=5.0, snapped=(200.0, 200.0)))
self.assertEqual(layer._features, [])
@@ -998,47 +1073,47 @@ class TestDeletePoint(_ModeTestCase):
def test_deletes_point_under_click(self):
layer = _layer_with_points([(30.0, 5.0), (60.0, 5.0)])
canvas = _FakeCanvas()
- plugin, _ = self._plugin(layer, canvas)
- plugin._delete_action.setChecked(True)
- plugin._on_pick(_FakeEvent(x=58.0, y=5.0))
+ session, _ = self._session(layer, canvas)
+ session._delete_action.setChecked(True)
+ session._on_pick(_FakeEvent(x=58.0, y=5.0))
self.assertEqual([f.id() for f in layer._features], [1])
self.assertEqual(canvas.refresh_count, 1)
def test_miss_is_a_no_op(self):
layer = _layer_with_points([(30.0, 5.0)])
- plugin, _ = self._plugin(layer)
- plugin._delete_action.setChecked(True)
- plugin._on_pick(_FakeEvent(x=45.0, y=5.0))
+ session, _ = self._session(layer)
+ session._delete_action.setChecked(True)
+ session._on_pick(_FakeEvent(x=45.0, y=5.0))
self.assertEqual(len(layer._features), 1)
def test_deleted_point_pruned_from_undo_history(self):
- plugin, _ = self._plugin()
- plugin._on_pick(_FakeEvent())
- plugin._on_pick(_FakeEvent())
- layer = plugin._layer
+ session, _ = self._session()
+ session._on_pick(_FakeEvent())
+ session._on_pick(_FakeEvent())
+ layer = session._plugin._layer
layer._features[0].setGeometry(fq.QgsGeometry(fq.QgsPoint(30.0, 0.0, 5.0)))
- plugin._delete_action.setChecked(True)
- plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
- self.assertEqual(plugin._history, [(layer, 2)])
- plugin._undo_last_point()
+ session._delete_action.setChecked(True)
+ session._on_pick(_FakeEvent(x=30.0, y=5.0))
+ self.assertEqual(session._history, [(layer, 2)])
+ session._undo_last_point()
self.assertEqual(layer._features, [])
- self.assertFalse(plugin._undo_action.isEnabled())
+ self.assertFalse(session._undo_action.isEnabled())
def test_read_only_layer_warns_and_keeps_point(self):
layer = _layer_with_points([(30.0, 5.0)])
layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures
- plugin, iface = self._plugin(layer)
- plugin._delete_action.setChecked(True)
- plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
+ session, iface = self._session(layer)
+ session._delete_action.setChecked(True)
+ session._on_pick(_FakeEvent(x=30.0, y=5.0))
self.assertEqual(len(layer._features), 1)
self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages))
def test_delete_failure_warns(self):
layer = _layer_with_points([(30.0, 5.0)])
- plugin, iface = self._plugin(layer)
- plugin._delete_action.setChecked(True)
+ session, iface = self._session(layer)
+ session._delete_action.setChecked(True)
fq._FakeDataProvider._fail_next_delete = True
- plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
+ session._on_pick(_FakeEvent(x=30.0, y=5.0))
self.assertEqual(len(layer._features), 1)
self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages))