1# This file is part of the Frescobaldi project, http://www.frescobaldi.org/ 2# 3# Copyright (c) 2008 - 2014 by Wilbert Berendsen 4# 5# This program is free software; you can redistribute it and/or 6# modify it under the terms of the GNU General Public License 7# as published by the Free Software Foundation; either version 2 8# of the License, or (at your option) any later version. 9# 10# This program is distributed in the hope that it will be useful, 11# but WITHOUT ANY WARRANTY; without even the implied warranty of 12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13# GNU General Public License for more details. 14# 15# You should have received a copy of the GNU General Public License 16# along with this program; if not, write to the Free Software 17# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA 18# See http://www.gnu.org/licenses/ for more information. 19 20""" 21ViewManager is a QSplitter containing sub-splitters to display multiple 22ViewSpaces. 23ViewSpace is a QStackedWidget with a statusbar, capable of displaying one of 24multiple views. 25""" 26 27 28import contextlib 29import weakref 30 31from PyQt5.QtCore import QEvent, Qt, pyqtSignal 32from PyQt5.QtGui import QKeySequence, QPixmap 33from PyQt5.QtWidgets import ( 34 QAction, QHBoxLayout, QLabel, QMenu, QProgressBar, QSplitter, 35 QStackedWidget, QVBoxLayout, QWidget) 36 37import actioncollection 38import app 39import icons 40import view as view_ 41import qutil 42 43 44class ViewStatusBar(QWidget): 45 def __init__(self, parent=None): 46 super(ViewStatusBar, self).__init__(parent) 47 48 layout = QHBoxLayout() 49 layout.setContentsMargins(2, 1, 0, 1) 50 layout.setSpacing(8) 51 self.setLayout(layout) 52 self.positionLabel = QLabel() 53 layout.addWidget(self.positionLabel) 54 55 self.stateLabel = QLabel() 56 self.stateLabel.setFixedSize(16, 16) 57 layout.addWidget(self.stateLabel) 58 59 self.infoLabel = QLabel(minimumWidth=10) 60 layout.addWidget(self.infoLabel, 1) 61 62 def event(self, ev): 63 if ev.type() == QEvent.MouseButtonPress: 64 if ev.button() == Qt.RightButton: 65 self.showContextMenu(ev.globalPos()) 66 else: 67 self.parent().activeView().setFocus() 68 return True 69 return super(ViewStatusBar, self).event(ev) 70 71 def showContextMenu(self, pos): 72 menu = QMenu(self) 73 menu.aboutToHide.connect(menu.deleteLater) 74 viewspace = self.parent() 75 manager = viewspace.manager() 76 77 a = QAction(icons.get('view-split-top-bottom'), _("Split &Horizontally"), menu) 78 menu.addAction(a) 79 a.triggered.connect(lambda: manager.splitViewSpace(viewspace, Qt.Vertical)) 80 a = QAction(icons.get('view-split-left-right'), _("Split &Vertically"), menu) 81 menu.addAction(a) 82 a.triggered.connect(lambda: manager.splitViewSpace(viewspace, Qt.Horizontal)) 83 menu.addSeparator() 84 a = QAction(icons.get('view-close'), _("&Close View"), menu) 85 a.triggered.connect(lambda: manager.closeViewSpace(viewspace)) 86 a.setEnabled(manager.canCloseViewSpace()) 87 menu.addAction(a) 88 89 menu.exec_(pos) 90 91 92class ViewSpace(QWidget): 93 """A ViewSpace manages a stack of views, one of them is visible. 94 95 The ViewSpace also has a statusbar, accessible in the status attribute. 96 The viewChanged(View) signal is emitted when the current view for this ViewSpace changes. 97 98 Also, when a ViewSpace is created (e.g. when a window is created or split), the 99 app.viewSpaceCreated(space) signal is emitted. 100 101 You can use the app.viewSpaceCreated() and the ViewSpace.viewChanged() signals to implement 102 things on a per ViewSpace basis, e.g. in the statusbar of a ViewSpace. 103 104 """ 105 viewChanged = pyqtSignal(view_.View) 106 107 def __init__(self, manager, parent=None): 108 super(ViewSpace, self).__init__(parent) 109 self.manager = weakref.ref(manager) 110 self.views = [] 111 112 layout = QVBoxLayout() 113 layout.setContentsMargins(0, 0, 0, 0) 114 layout.setSpacing(0) 115 self.setLayout(layout) 116 self.stack = QStackedWidget(self) 117 layout.addWidget(self.stack) 118 self.status = ViewStatusBar(self) 119 self.status.setEnabled(False) 120 layout.addWidget(self.status) 121 app.languageChanged.connect(self.updateStatusBar) 122 app.viewSpaceCreated(self) 123 124 def activeView(self): 125 if self.views: 126 return self.views[-1] 127 128 def document(self): 129 """Returns the currently active document in this space. 130 131 If there are no views, returns None. 132 133 """ 134 if self.views: 135 return self.views[-1].document() 136 137 def showDocument(self, doc): 138 """Shows the document, creating a View if necessary.""" 139 if doc is self.document(): 140 return 141 cur = self.activeView() 142 for view in self.views[:-1]: 143 if doc is view.document(): 144 self.views.remove(view) 145 break 146 else: 147 view = view_.View(doc) 148 self.stack.addWidget(view) 149 self.views.append(view) 150 if cur: 151 self.disconnectView(cur) 152 self.connectView(view) 153 self.stack.setCurrentWidget(view) 154 self.updateStatusBar() 155 156 def removeDocument(self, doc): 157 active = doc is self.document() 158 if active: 159 self.disconnectView(self.activeView()) 160 for view in self.views: 161 if doc is view.document(): 162 self.views.remove(view) 163 view.deleteLater() 164 break 165 else: 166 return 167 if active and self.views: 168 self.connectView(self.views[-1]) 169 self.stack.setCurrentWidget(self.views[-1]) 170 self.updateStatusBar() 171 172 def connectView(self, view): 173 view.installEventFilter(self) 174 view.cursorPositionChanged.connect(self.updateCursorPosition) 175 view.modificationChanged.connect(self.updateModificationState) 176 view.document().urlChanged.connect(self.updateDocumentName) 177 self.viewChanged.emit(view) 178 179 def disconnectView(self, view): 180 view.removeEventFilter(self) 181 view.cursorPositionChanged.disconnect(self.updateCursorPosition) 182 view.modificationChanged.disconnect(self.updateModificationState) 183 view.document().urlChanged.disconnect(self.updateDocumentName) 184 185 def eventFilter(self, view, ev): 186 if ev.type() == QEvent.FocusIn: 187 self.setActiveViewSpace() 188 return False 189 190 def setActiveViewSpace(self): 191 self.manager().setActiveViewSpace(self) 192 193 def updateStatusBar(self): 194 """Update all info in the statusbar, e.g. on document change.""" 195 if self.views: 196 self.updateCursorPosition() 197 self.updateModificationState() 198 self.updateDocumentName() 199 200 def updateCursorPosition(self): 201 cur = self.activeView().textCursor() 202 line = cur.blockNumber() + 1 203 try: 204 column = cur.positionInBlock() 205 except AttributeError: # only in very recent PyQt5 206 column = cur.position() - cur.block().position() 207 self.status.positionLabel.setText(_("Line: {line}, Col: {column}").format( 208 line = line, column = column)) 209 210 def updateModificationState(self): 211 modified = self.document().isModified() 212 pixmap = icons.get('document-save').pixmap(16) if modified else QPixmap() 213 self.status.stateLabel.setPixmap(pixmap) 214 215 def updateDocumentName(self): 216 self.status.infoLabel.setText(self.document().documentName()) 217 218 219class ViewManager(QSplitter): 220 221 # This signal is always emitted on setCurrentDocument, 222 # even if the view is the same as before. 223 # use MainWindow.currentViewChanged() to be informed about 224 # real View changes. 225 viewChanged = pyqtSignal(view_.View) 226 227 # This signal is emitted when another ViewSpace becomes active. 228 activeViewSpaceChanged = pyqtSignal(ViewSpace, ViewSpace) 229 230 def __init__(self, parent=None): 231 super(ViewManager, self).__init__(parent) 232 self._viewSpaces = [] 233 234 viewspace = ViewSpace(self) 235 viewspace.status.setEnabled(True) 236 self.addWidget(viewspace) 237 self._viewSpaces.append(viewspace) 238 239 self.createActions() 240 app.documentClosed.connect(self.slotDocumentClosed) 241 242 def setCurrentDocument(self, doc, findOpenView=False): 243 if doc is not self.activeViewSpace().document(): 244 done = False 245 if findOpenView: 246 for space in self._viewSpaces[-2::-1]: 247 if doc is space.document(): 248 done = True 249 self.setActiveViewSpace(space) 250 break 251 if not done: 252 self.activeViewSpace().showDocument(doc) 253 self.viewChanged.emit(self.activeViewSpace().activeView()) 254 # the active space now displays the requested document 255 # now also set this document in spaces that are empty 256 for space in self._viewSpaces[:-1]: 257 if not space.document(): 258 space.showDocument(doc) 259 self.focusActiveView() 260 261 def focusActiveView(self): 262 self.activeViewSpace().activeView().setFocus() 263 264 def setActiveViewSpace(self, space): 265 prev = self._viewSpaces[-1] 266 if space is prev: 267 return 268 self._viewSpaces.remove(space) 269 self._viewSpaces.append(space) 270 prev.status.setEnabled(False) 271 space.status.setEnabled(True) 272 self.activeViewSpaceChanged.emit(space, prev) 273 self.viewChanged.emit(space.activeView()) 274 275 def slotDocumentClosed(self, doc): 276 activeDocument = self.activeViewSpace().document() 277 for space in self._viewSpaces: 278 space.removeDocument(doc) 279 if doc is not activeDocument: 280 # setCurrentDocument will not be called, fill empty spaces with our 281 # document. 282 for space in self._viewSpaces[:-1]: 283 if not space.document(): 284 space.showDocument(activeDocument) 285 286 def createActions(self): 287 self.actionCollection = ac = ViewActions() 288 # connections 289 ac.window_close_view.setEnabled(False) 290 ac.window_close_others.setEnabled(False) 291 ac.window_split_horizontal.triggered.connect(self.splitCurrentVertical) 292 ac.window_split_vertical.triggered.connect(self.splitCurrentHorizontal) 293 ac.window_close_view.triggered.connect(self.closeCurrent) 294 ac.window_close_others.triggered.connect(self.closeOthers) 295 ac.window_next_view.triggered.connect(self.nextViewSpace) 296 ac.window_previous_view.triggered.connect(self.previousViewSpace) 297 298 def splitCurrentVertical(self): 299 self.splitViewSpace(self.activeViewSpace(), Qt.Vertical) 300 301 def splitCurrentHorizontal(self): 302 self.splitViewSpace(self.activeViewSpace(), Qt.Horizontal) 303 304 def closeCurrent(self): 305 self.closeViewSpace(self.activeViewSpace()) 306 307 def closeOthers(self): 308 for space in self._viewSpaces[-2::-1]: 309 self.closeViewSpace(space) 310 311 def nextViewSpace(self): 312 self.focusNextChild() 313 314 def previousViewSpace(self): 315 self.focusPreviousChild() 316 317 def activeViewSpace(self): 318 return self._viewSpaces[-1] 319 320 def splitViewSpace(self, viewspace, orientation): 321 """Split the given view. 322 323 If orientation == Qt.Horizontal, adds a new view to the right. 324 If orientation == Qt.Vertical, adds a new view to the bottom. 325 326 """ 327 active = viewspace is self.activeViewSpace() 328 splitter = viewspace.parentWidget() 329 newspace = ViewSpace(self) 330 331 if splitter.count() == 1: 332 splitter.setOrientation(orientation) 333 size = splitter.sizes()[0] 334 splitter.addWidget(newspace) 335 splitter.setSizes([size / 2, size / 2]) 336 elif splitter.orientation() == orientation: 337 index = splitter.indexOf(viewspace) 338 splitter.insertWidget(index + 1, newspace) 339 else: 340 index = splitter.indexOf(viewspace) 341 newsplitter = QSplitter() 342 newsplitter.setOrientation(orientation) 343 sizes = splitter.sizes() 344 splitter.insertWidget(index, newsplitter) 345 newsplitter.addWidget(viewspace) 346 splitter.setSizes(sizes) 347 size = newsplitter.sizes()[0] 348 newsplitter.addWidget(newspace) 349 newsplitter.setSizes([size / 2, size / 2]) 350 self._viewSpaces.insert(0, newspace) 351 newspace.showDocument(viewspace.document()) 352 if active: 353 newspace.activeView().setFocus() 354 self.actionCollection.window_close_view.setEnabled(self.canCloseViewSpace()) 355 self.actionCollection.window_close_others.setEnabled(self.canCloseViewSpace()) 356 357 def closeViewSpace(self, viewspace): 358 """Closes the given view.""" 359 active = viewspace is self.activeViewSpace() 360 if active: 361 self.setActiveViewSpace(self._viewSpaces[-2]) 362 splitter = viewspace.parentWidget() 363 if splitter.count() > 2: 364 viewspace.setParent(None) 365 viewspace.deleteLater() 366 elif splitter is self: 367 if self.count() < 2: 368 return 369 # we contain only one other widget. 370 # if that is a QSplitter, add all its children to ourselves 371 # and copy the sizes and orientation. 372 other = self.widget(1 - self.indexOf(viewspace)) 373 viewspace.setParent(None) 374 viewspace.deleteLater() 375 if isinstance(other, QSplitter): 376 sizes = other.sizes() 377 self.setOrientation(other.orientation()) 378 while other.count(): 379 self.insertWidget(0, other.widget(other.count()-1)) 380 other.setParent(None) 381 other.deleteLater() 382 self.setSizes(sizes) 383 else: 384 # this splitter contains only one other widget. 385 # if that is a ViewSpace, just add it to the parent splitter. 386 # if it is a splitter, add all widgets to the parent splitter. 387 other = splitter.widget(1 - splitter.indexOf(viewspace)) 388 parent = splitter.parentWidget() 389 sizes = parent.sizes() 390 index = parent.indexOf(splitter) 391 392 if isinstance(other, ViewSpace): 393 parent.insertWidget(index, other) 394 else: 395 #QSplitter 396 sizes[index:index+1] = other.sizes() 397 while other.count(): 398 parent.insertWidget(index, other.widget(other.count()-1)) 399 viewspace.setParent(None) 400 splitter.setParent(None) 401 viewspace.deleteLater() 402 splitter.deleteLater() 403 parent.setSizes(sizes) 404 self._viewSpaces.remove(viewspace) 405 self.actionCollection.window_close_view.setEnabled(self.canCloseViewSpace()) 406 self.actionCollection.window_close_others.setEnabled(self.canCloseViewSpace()) 407 408 def canCloseViewSpace(self): 409 return bool(self.count() > 1) 410 411 412 413 414class ViewActions(actioncollection.ActionCollection): 415 name = "view" 416 def createActions(self, parent=None): 417 self.window_split_horizontal = QAction(parent) 418 self.window_split_vertical = QAction(parent) 419 self.window_close_view = QAction(parent) 420 self.window_close_others = QAction(parent) 421 self.window_next_view = QAction(parent) 422 self.window_previous_view = QAction(parent) 423 424 # icons 425 self.window_split_horizontal.setIcon(icons.get('view-split-top-bottom')) 426 self.window_split_vertical.setIcon(icons.get('view-split-left-right')) 427 self.window_close_view.setIcon(icons.get('view-close')) 428 self.window_next_view.setIcon(icons.get('go-next-view')) 429 self.window_previous_view.setIcon(icons.get('go-previous-view')) 430 431 # shortcuts 432 self.window_close_view.setShortcut(Qt.CTRL + Qt.SHIFT + Qt.Key_W) 433 self.window_next_view.setShortcuts(QKeySequence.NextChild) 434 qutil.removeShortcut(self.window_next_view, "Ctrl+,") 435 self.window_previous_view.setShortcuts(QKeySequence.PreviousChild) 436 qutil.removeShortcut(self.window_previous_view, "Ctrl+.") 437 438 def translateUI(self): 439 self.window_split_horizontal.setText(_("Split &Horizontally")) 440 self.window_split_vertical.setText(_("Split &Vertically")) 441 self.window_close_view.setText(_("&Close Current View")) 442 self.window_close_others.setText(_("Close &Other Views")) 443 self.window_next_view.setText(_("&Next View")) 444 self.window_previous_view.setText(_("&Previous View")) 445