Source code for tuiview.geolinkedviewers


"""
Contains the GeolinkedViewers class.
"""
# This file is part of 'TuiView' - a simple Raster viewer
# Copyright (C) 2012  Sam Gillingham
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.

import math
import json
from PySide6.QtCore import QObject, QTimer, Qt, QEventLoop, Signal
from PySide6.QtWidgets import QApplication

from . import viewerwindow
from . import pluginmanager
from .querywindow import QueryDockWidget
from .viewerlayers import ViewerQueryPointLayer, ViewerFeatureVectorLayer
from . import viewerwidget


[docs]class GeolinkedViewers(QObject): """ Class that manages a collection of ViewerWindows that have their widgets geolinked. """ # signals newViewerCreated = Signal(viewerwindow.ViewerWindow, name='newViewerCreated') "signal emitted when a new viewer window is created" stateRepeatActive = Signal(bool, name='stateRepeatChanged') "signal emitted when a save state timer is active/not active" lastViewerClosed = Signal(name='lastViewerClosed') "signal emitted when there are no viewers left" def __init__(self, loadPlugins=True): QObject.__init__(self) # need to keep a reference to keep the python objects alive # otherwise they are deleted before they are shown self.viewers = [] self.plugins = [] # keep the last geolink info in case we need it self.last_geolink_obj = None # load plugins if asked if loadPlugins: self.pluginmanager = pluginmanager.PluginManager() self.pluginmanager.loadPlugins() # do the init action self.pluginmanager.callAction(pluginmanager.PLUGIN_ACTION_INIT, self) else: self.pluginmanager = None # set up a timer so we can periodically remove viewer # instances when they are no longer open to save memory # Usually, in PyQt you don't have such a 'dynamic' # number of sub windows. self.timer = QTimer(self) self.timer.timeout.connect(self.cleanUp) self.timer.start(10000) # 10 secs # timer for saving the state periodically self.saveStateTimer = QTimer(self) self.saveStateTimer.timeout.connect(self.onSaveStateTimer) self.saveStateTimerFilename = None
[docs] @staticmethod def getViewerList(screen=None): """ Gets the list of current viewer windows from Qt. Pass in a screen to restrict to the viewers on that screen """ viewers = [] for viewer in QApplication.topLevelWidgets(): if (isinstance(viewer, viewerwindow.ViewerWindow) and viewer.isVisible()): if screen is not None: winHandle = viewer.windowHandle() screen2 = winHandle.screen() if screen2 is not None and screen.name() != screen2.name(): continue viewers.append(viewer) return viewers
[docs] def cleanUp(self): "remove any viewers that are no longer in the activelist" activeviewers = self.getViewerList() # remove any viewers that are no longer in the activelist # (they must have been closed) # they should now be cleaned up by Python and memory released self.viewers = [viewer for viewer in self.viewers if viewer in activeviewers] # workaround for app not closing under Wayland if len(self.viewers) == 0: self.lastViewerClosed.emit()
[docs] def closeAll(self): """ Call this to close all geolinked viewers """ for viewer in self.viewers: viewer.close() self.viewers = []
[docs] def setActiveToolAll(self, tool, senderid): """ sets the specified tool as active on all the viewers """ for viewer in self.viewers: viewer.viewwidget.setActiveTool(tool, senderid)
[docs] def setQueryPointAll(self, senderid, easting, northing, color, size=None, cursor=None): """ Calls setQueryPoint on all the widgets """ for viewer in self.viewers: viewer.viewwidget.setQueryPoint(senderid, easting, northing, color, size, cursor)
[docs] def removeQueryPointAll(self, senderid): """ Calls removeQueryPoint on all the widgets """ for viewer in self.viewers: viewer.viewwidget.removeQueryPoint(senderid)
[docs] def newViewer(self, filename=None, stretch=None): """ Call this to create a new geolinked viewer. Returns the created ViewerWindow instance. """ newviewer = viewerwindow.ViewerWindow() newviewer.show() # connect signals self.connectSignals(newviewer) # open the file if we have one if filename is not None: newviewer.addRasterInternal(filename, stretch) self.viewers.append(newviewer) # call any plugins if self.pluginmanager is not None: self.pluginmanager.callAction( pluginmanager.PLUGIN_ACTION_NEWVIEWER, newviewer) # emit a signal so that application can do any customisation self.newViewerCreated.emit(newviewer) # return it return newviewer
[docs] def connectSignals(self, newviewer): """ Connects the appropriate signals for the new viewer """ # connect to the signal the widget sends when moved # sends new easting, northing and id() of the widget. newviewer.viewwidget.geolinkMove.connect(self.onMove) # the signal when a new query point is chosen # on a widget. Sends easting, northing and id() of the widget newviewer.viewwidget.geolinkQueryPoint.connect(self.onQuery) # signal when a new layer is added newviewer.viewwidget.layerAdded.connect(self.layerAdded) # signal for request for new window newviewer.newWindowSig.connect(self.onNewWindow) # signal for request for windows to be tiled newviewer.tileWindowsSig.connect(self.onTileWindows) # signal for new query window been opened newviewer.newQueryWindowSig.connect(self.onNewQueryWindow) # signal for new stretch window been opened newviewer.newStretchWindowSig.connect(self.onNewStretchWindow) # signal for new layer window been opened newviewer.newLayerWindowSig.connect(self.onNewLayerWindow) # signal for new profile window been opened newviewer.newProfileWindowSig.connect(self.onNewProfileWindow) # signal for new vector query window been opened newviewer.newVectorQueryWindowSig.connect(self.onNewVectorQueryWindow) # signal for closing all windows newviewer.closeAllWindowsSig.connect(self.closeAll) # signal for request to write viewers state to a file newviewer.writeViewersState.connect(self.writeViewersState) # signal for request to read viewers state from file newviewer.readViewersState.connect(self.readViewersState) # signal for canceling a viewer state write timer newviewer.cancelViewersStateTimer.connect(self.cancelViewerStateTimer) # subscribe the new viewer to the function that enables cancel menu item self.stateRepeatActive.connect(newviewer.saveViewersTimerActiveStateChanged) # trigger it newviewer.saveViewersTimerActiveStateChanged(self.saveStateTimer.isActive())
[docs] def onNewWindow(self): """ Called when the user requests a new window """ newviewer = viewerwindow.ViewerWindow() newviewer.show() # connect signals self.connectSignals(newviewer) self.viewers.append(newviewer) # call any plugins if self.pluginmanager is not None: self.pluginmanager.callAction( pluginmanager.PLUGIN_ACTION_NEWVIEWER, newviewer) # emit a signal so that application can do any customisation self.newViewerCreated.emit(newviewer) return newviewer
[docs] def onNewQueryWindow(self, querywindow): """ Called when the viewer starts a new query window """ # call any plugins if self.pluginmanager is not None: self.pluginmanager.callAction( pluginmanager.PLUGIN_ACTION_NEWQUERY, querywindow)
[docs] def onNewStretchWindow(self, stretchwindow): """ Called when the viewer starts a new stretch window """ # call any plugins if self.pluginmanager is not None: self.pluginmanager.callAction( pluginmanager.PLUGIN_ACTION_NEWSTRETCH, stretchwindow)
[docs] def onNewLayerWindow(self, layerwindow): """ Called when the viewer starts a new layer window """ # call any plugins if self.pluginmanager is not None: self.pluginmanager.callAction( pluginmanager.PLUGIN_ACTION_NEWLAYER, layerwindow)
[docs] def onNewProfileWindow(self, profilewindow): """ Called when the viewer starts a new profile window """ # call any plugins if self.pluginmanager is not None: self.pluginmanager.callAction( pluginmanager.PLUGIN_ACTION_NEWPROFILE, profilewindow)
[docs] def onNewVectorQueryWindow(self, vectorwindow): """ Called when the viewer starts a new vector query window """ # call any plugins if self.pluginmanager is not None: self.pluginmanager.callAction( pluginmanager.PLUGIN_ACTION_NEWVECTORQUERY, vectorwindow)
[docs] def getDesktopSize(self, screen): """ Called at the start of the tiling operation. Default implementation just gets the size of the desktop. if overridden, return a QRect """ if screen is None: return QApplication.desktop().availableGeometry() else: return screen.availableGeometry()
[docs] def onTileWindows(self, nxside, nyside, screen): """ Called when the user wants the windows to be tiled """ # get the dimensions of the desktop desktop = self.getDesktopSize(screen) # getViewerList returns a temporary list so we can stuff around with it viewerList = self.getViewerList(screen) # do they want full auto? if nxside == 0 and nyside == 0: # find the number of viewers along each side nxside = math.sqrt(len(self.viewers)) # round up - we may end up with gaps nxside = int(math.ceil(nxside)) nyside = int(math.ceil(len(self.viewers) / float(nxside))) elif nxside == 0 and nyside != 0: # guess nxside nxside = int(math.ceil(len(self.viewers) / float(nyside))) elif nxside != 0 and nyside == 0: # guess yxside nyside = int(math.ceil(len(self.viewers) / float(nxside))) # size of each viewer window viewerwidth = int(desktop.width() / nxside) viewerheight = int(desktop.height() / nyside) # there is a problem where resize() doesn't include the frame # area so we have to calculate it ourselves. This is the best # I could come up with geom = self.viewers[0].geometry() framegeom = self.viewers[0].frameGeometry() framewidth = framegeom.width() - geom.width() frameheight = framegeom.height() - geom.height() # now resize and move the viewers xcount = 0 ycount = 0 while len(viewerList) > 0: # work out the location we will use and find the viewer closest xloc = desktop.x() + viewerwidth * xcount yloc = desktop.y() + viewerheight * ycount def viewerKey(a): xdist = abs(a.x() - xloc) ydist = abs(a.y() - yloc) return math.sqrt(xdist * xdist + ydist * ydist) # sort by distance from this location viewerList = sorted(viewerList, key=viewerKey) # closest viewer = viewerList.pop(0) # remove any maximised states - window manager will not let # use resize state = viewer.windowState() if (state & Qt.WindowMaximized) == Qt.WindowMaximized: viewer.setWindowState(state ^ Qt.WindowMaximized) # resize takes the area without the frame so we correct for that viewer.resize(viewerwidth - framewidth, viewerheight - frameheight) # remember that taskbar etc mean that we might not want # to start at 0,0 viewer.move(xloc, yloc) # raise it to the top in case it is behind other windows viewer.raise_() xcount += 1 if xcount >= nxside: xcount = 0 ycount += 1
[docs] def layerAdded(self, widget): """ Called when a new layer is added. If it is the first layer and there is a last_geolink_obj active, then send a doGeoLinkMove so it gets the location we are working in """ if len(widget.layers.layers) == 1 and self.last_geolink_obj is not None: widget.doGeolinkMove(self.last_geolink_obj.easting, self.last_geolink_obj.northing, self.last_geolink_obj.metresperwinpix)
[docs] def onMove(self, obj): """ Called when a widget signals it has moved. Move all the other widgets. A GeolinkInfo object is passed. Sends the id() of the widget and uses this to not move the original widget """ # paint any windows that are ready QApplication.processEvents(QEventLoop.ExcludeUserInputEvents) for viewer in self.getViewerList(): # we use the id() of the widget to # identify them. if id(viewer.viewwidget) != obj.senderid: viewer.viewwidget.doGeolinkMove(obj.easting, obj.northing, obj.metresperwinpix) # paint any windows that are ready QApplication.processEvents(QEventLoop.ExcludeUserInputEvents) # save it self.last_geolink_obj = obj
[docs] def onQuery(self, obj): """ Called when a widget signals the query point has moved. Notify the other widgets. A GeolinkInfo object is passed. Sends the id() of the widget and uses this not to notify the original widget """ for viewer in self.getViewerList(): # we use the id() of the widget to # identify them. if id(viewer.viewwidget) != obj.senderid: viewer.viewwidget.doGeolinkQueryPoint(obj.easting, obj.northing)
[docs] def writeViewersState(self, fname, repeat_secs, cancelTimer=True): """ Gets the state of all the viewers (location, layers etc) as a json encoded string and write it to fileobj. Pass repeat_secs=0 when no automatic timer needs to be set Set cancelTimer to True unless this function is being called from the timer. """ # stop timer now to prevent confusion if cancelTimer: self.saveStateTimer.stop() self.saveStateTimerFilename = None # for the case where they are doing a save but there is a timer running # cancel that in the UI self.stateRepeatActive.emit(False) viewers = self.getViewerList() with open(fname, 'w') as fileobj: # see if we can get a GeolinkInfo # should all be the same since geolinked geolinkStr = 'None' for viewer in viewers: info = viewer.viewwidget.getGeolinkInfo() if info is not None: geolinkStr = info.toString() break s = json.dumps({'name': 'tuiview', 'nviewers': len(viewers), 'geolink': geolinkStr}) + '\n' fileobj.write(s) for viewer in viewers: pos = viewer.pos() # we have to be careful since not all layer types # are saved. Must be a better way... nlayers = 0 for layer in viewer.viewwidget.layers.layers: if (not isinstance(layer, ViewerQueryPointLayer) and not isinstance(layer, ViewerFeatureVectorLayer)): nlayers += 1 viewerDict = {'nlayers': nlayers, 'x': pos.x(), 'y': pos.y(), 'width': viewer.width(), 'height': viewer.height()} winHandle = viewer.windowHandle() # save which screen this is on screen = winHandle.screen() if screen is not None: viewerDict['screen'] = screen.name() # querywindow situation query_wins = viewer.findChildren(QueryDockWidget) if len(query_wins) > 0: query_win = query_wins[0] # check it's visible - can still exist after hidden. if query_win.isVisible(): qpos = query_win.pos() query_win_data = {'x': qpos.x(), 'y': qpos.y(), 'width': query_win.width(), 'height': query_win.height()} if query_win.lastqi is not None: query_win_data['easting'] = query_win.lastqi.easting query_win_data['northing'] = query_win.lastqi.northing viewerDict['querywindow'] = query_win_data # TODO: maybe other dock widgets (profile etc?) s = json.dumps(viewerDict) + '\n' fileobj.write(s) # now get the layers to write themselves out viewer.viewwidget.layers.toFile(fileobj) # did they ask for periodic? # (note timer stopped above) if repeat_secs != 0: self.saveStateTimerFilename = fname self.saveStateTimer.start(repeat_secs * 1000) # convert to milliseconds self.stateRepeatActive.emit(True)
[docs] def readViewersState(self, fname): """ Reads viewer state from the fname and restores viewers """ with open(fname) as fileobj: headerDict = json.loads(fileobj.readline()) if 'name' not in headerDict or headerDict['name'] != 'tuiview': raise ValueError('File not written by tuiview') geolinkStr = headerDict['geolink'] if geolinkStr != 'None': geolink = viewerwidget.GeolinkInfo.fromString(geolinkStr) else: geolink = None # get all the screens connected screenDict = {} screens = QApplication.screens() for screen in screens: screenDict[screen.name()] = screen # set this if we have a valid query window with a valid location query_easting_northing = (None, None) query_viewer = None # so we can grab the last one for _ in range(headerDict['nviewers']): viewer = self.onNewWindow() viewerDict = json.loads(fileobj.readline()) viewer.addLayersFromJSONFile(fileobj, viewerDict['nlayers']) if 'screen' in viewerDict: winHandle = viewer.windowHandle() screenName = viewerDict['screen'] if screenName in screenDict: screen = screenDict[screenName] winHandle.setScreen(screen) # do this last in case only makes sense on new window viewer.move(viewerDict['x'], viewerDict['y']) viewer.resize(viewerDict['width'], viewerDict['height']) # any sub windows? if 'querywindow' in viewerDict: query_win_data = viewerDict['querywindow'] # the best way to do this ended up by invoking the action # as the button state ended up being correct etc viewer.queryAct.setChecked(True) # should be only one child of this type qw = viewer.findChildren(QueryDockWidget)[0] qw.move(query_win_data['x'], query_win_data['y']) qw.resize(query_win_data['width'], query_win_data['height']) # any location? if 'easting' in query_win_data and 'northing' in query_win_data: query_easting_northing = (query_win_data['easting'], query_win_data['northing']) query_viewer = viewer # do now so all the windows get it (using the last one) qeasting, qnorthing = query_easting_northing if qeasting is not None: query_viewer.viewwidget.newQueryPoint(qeasting, qnorthing) # set the location if any if geolink is not None: self.onMove(geolink)
[docs] def onSaveStateTimer(self): """ Timer has triggered. Save file again, but don't do anything to the timer. """ self.writeViewersState(self.saveStateTimerFilename, 0, False)
[docs] def cancelViewerStateTimer(self): """ One of the viewers cancelled the state timer """ self.saveStateTimer.stop() self.saveStateTimerFilename = None self.stateRepeatActive.emit(False)