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