######################################################################################################################
# Copyright (C) 2017-2022 Spine project consortium
# Copyright Spine Toolbox contributors
# This file is part of Spine Toolbox.
# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General
# Public License as published by the Free Software Foundation, either version 3 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 Lesser General
# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
######################################################################################################################
"""Contains the MultiTabWindow and TabBarPlus classes."""
from PySide6.QtWidgets import QMainWindow, QTabWidget, QWidget, QTabBar, QToolButton, QApplication, QMenu
from PySide6.QtCore import Qt, Slot, QPoint, Signal, QEvent
from PySide6.QtGui import QGuiApplication, QCursor, QIcon, QMouseEvent
from ..helpers import ensure_window_is_on_screen, CharIconEngine
[docs]class MultiTabWindow(QMainWindow):
"""A main window that has a tab widget as its central widget."""
[docs] _other_editor_windows = {}
def __init__(self, qsettings, settings_group):
"""
Args:
qsettings (QSettings): Toolbox settings
settings_group (str): this window's settings group in ``qsettings``
"""
super().__init__()
self._other_editor_windows.setdefault(type(self).__name__, []).append(self)
self.qsettings = qsettings
self.settings_group = settings_group
self.tab_widget = QTabWidget(self)
self.tab_bar = TabBarPlus(self)
self.tab_widget.setTabBar(self.tab_bar)
self.setCentralWidget(self.tab_widget)
self.setAttribute(Qt.WA_DeleteOnClose)
self._hot_spot = None
self._timer_id = None
self._accepting_new_tabs = True
self.restore_ui()
self.connect_signals()
for w in self.findChildren(QWidget):
w.setFocusPolicy(Qt.ClickFocus)
@property
[docs] def accepting_new_tabs(self):
return self._accepting_new_tabs
[docs] def _make_other(self):
"""Creates a new MultiTabWindow of this type.
Returns:
MultiTabWindow: new MultiTabWindow
"""
raise NotImplementedError()
[docs] def others(self):
"""List of other MultiTabWindows of the same type.
Returns:
list of MultiTabWindow: other MultiTabWindows windows
"""
return [w for w in self._other_editor_windows[type(self).__name__] if w is not self]
[docs] def _make_new_tab(self, *args, **kwargs):
"""Creates a new tab.
Args:
*args: positional arguments neede to make a new tab
**kwargs: keyword arguments needed to make a new tab
"""
raise NotImplementedError()
@property
[docs] def new_tab_title(self):
"""Title for new tabs."""
return "New tab"
[docs] def connect_signals(self):
"""Connects window's signals."""
self.tab_widget.tabCloseRequested.connect(self._close_tab)
self.tab_bar.plus_clicked.connect(self.add_new_tab)
[docs] def name(self):
"""Generates name based on the current tab and total tab count.
Returns:
str: a name
"""
name = self.tab_widget.tabText(self.tab_widget.currentIndex())
other_tab_count = self.tab_widget.count() - 1
if other_tab_count > 0:
name += f" and {other_tab_count} other tab"
if other_tab_count > 1:
name += "s"
return name
[docs] def all_tabs(self):
"""Iterates over tab contents widgets.
Yields:
QWidget: tab contents widget
"""
for k in range(self.tab_widget.count()):
yield self.tab_widget.widget(k)
@Slot()
[docs] def add_new_tab(self, *args, **kwargs):
"""Creates a new tab and adds it at the end of the tab bar.
Args:
*args: parameters forwarded to :func:`MutliTabWindow._make_new_tab`
**kwargs: parameters forwarded to :func:`MultiTabwindow._make_new_tab`
Returns:
bool: True if successful, False otherwise
"""
if not self._accepting_new_tabs:
return False
tab = self._make_new_tab(*args, **kwargs)
if not tab:
return False
self._add_connect_tab(tab, self.new_tab_title)
return True
[docs] def insert_new_tab(self, index, *args, **kwargs):
"""Creates a new tab and inserts it at the given index.
Args:
index (int): insertion point index
*args: parameters forwarded to :func:`MutliTabWindow._make_new_tab`
**kwargs: parameters forwarded to :func:`MultiTabwindow._make_new_tab`
"""
if not self._accepting_new_tabs:
return
tab = self._make_new_tab(*args, **kwargs)
self._insert_connect_tab(index, tab, self.new_tab_title)
[docs] def _add_connect_tab(self, tab, text):
"""Appends a new tab and connects signals.
Args:
tab (QWidget): tab contents widget
text (str): appended tab title
"""
self.tab_widget.addTab(tab, text)
self._connect_tab_signals(tab)
self.tab_widget.setCurrentIndex(self.tab_widget.count() - 1)
[docs] def _insert_connect_tab(self, index, tab, text):
"""Inserts a new tab and connects signals.
Args:
index (int): insertion point index
tab (QWidget): tab contents widget
text (str): inserted tab title
"""
self.tab_widget.insertTab(index, tab, text)
if not tab.windowTitle():
tab.setWindowTitle(text)
self._connect_tab_signals(tab)
self.tab_widget.setCurrentIndex(index)
[docs] def _remove_disconnect_tab(self, index):
"""Disconnects and removes a tab.
Args:
index (int): tab index
"""
self._disconnect_tab_signals(index)
self.tab_widget.removeTab(index)
[docs] def _connect_tab(self, index):
"""Connects signals from a tab contents widget.
Args:
index (int): tab index
"""
tab = self.tab_widget.widget(index)
self._connect_tab_signals(tab)
[docs] def _connect_tab_signals(self, tab):
"""Connects signals from a tab contents widget.
Args:
tab (QWidget): tab contents widget
Returns:
bool: True if signals were connected successfully, False otherwise
"""
if tab in self._tab_slots:
return False
slot = lambda title, tab=tab: self._handle_tab_window_title_changed(tab, title)
self._tab_slots[tab] = slot
tab.windowTitleChanged.connect(slot)
self._handle_tab_window_title_changed(tab, tab.windowTitle())
return True
[docs] def _disconnect_tab_signals(self, index):
"""Disconnects signals from given tab.
Args:
index (int): tab index
Returns:
bool: True if signals were disconnected successfully, False otherwise
"""
tab = self.tab_widget.widget(index)
slot = self._tab_slots.pop(tab, None)
if slot is None:
return False
tab.windowTitleChanged.disconnect(slot)
return True
[docs] def _handle_tab_window_title_changed(self, tab, title):
"""Updates tab's title.
Args:
tab (QWidget): tab's content widget
title (str): new tab title; if emtpy, one will be generated
"""
dirty = tab.isWindowModified()
for k in range(self.tab_widget.count()):
if not title:
title = self.new_tab_title
if self.tab_widget.widget(k) == tab:
mark = "*" if dirty else ""
self.tab_widget.setTabText(k, title + mark)
break
[docs] def _take_tab(self, index):
"""Removes a tab and returns its contents.
Args:
index (int): tab index
Returns:
tuple: widget the tab was holding and tab's title
"""
tab = self.tab_widget.widget(index)
text = self.tab_widget.tabText(index)
self._remove_disconnect_tab(index)
if not self.tab_widget.count():
self.close()
return tab, text
[docs] def move_tab(self, index, other=None):
"""Moves a tab to another MultiTabWindow.
Args:
index (int): tab index
other (MultiTabWindow, optional): target window; if None, creates a new window
"""
if other is None:
other = self._make_other()
other.show()
other._add_connect_tab(*self._take_tab(index))
other.raise_()
[docs] def detach(self, index, hot_spot, offset=0):
"""Detaches the tab at given index into another MultiTabWindow window and starts dragging it.
Args:
index (int)
hot_spot (QPoint)
offset (int)
"""
other = self._make_other()
other.tab_widget.addTab(*self._take_tab(index))
other.show()
other.start_drag(hot_spot, offset)
[docs] def start_drag(self, hot_spot, offset=0):
"""Starts dragging a detached tab.
Args:
hot_spot (QPoint): The anchor point of the drag in widget coordinates.
offset (int): Horizontal offset of the tab in the bar.
"""
self.setStyleSheet(f"QTabWidget::tab-bar {{left: {offset}px;}}")
self._hot_spot = hot_spot
self.grabMouse()
self._timer_id = self.startTimer(1000 // 60) # 60 fps is supposed to be the maximum the eye can see
[docs] def _frame_height(self):
"""Calculates the total 'thickness' of window frame in vertical direction.
Returns:
int: frame height
"""
return self.frameGeometry().height() - self.geometry().height()
[docs] def timerEvent(self, event):
"""Performs the drag, i.e., moves the window with the mouse cursor.
As soon as the mouse hovers the tab bar of another MultiTabWindow, reattaches it.
"""
if self._hot_spot is not None:
self.move(QCursor.pos() - self._hot_spot - QPoint(0, self._frame_height()))
for other in self.others():
index = other.tab_bar.index_under_mouse()
if index is not None:
db_editor = self.tab_widget.widget(0)
text = self.tab_widget.tabText(0)
self._remove_disconnect_tab(0)
self.close()
other.reattach(index, db_editor, text)
break
[docs] def mouseReleaseEvent(self, event):
"""Stops the drag. This only happens when the detached tab is not reattached to another window."""
super().mouseReleaseEvent(event)
if self._hot_spot:
self.setStyleSheet("")
self.releaseMouse()
self.killTimer(self._timer_id)
self._hot_spot = None
self.update()
if self.tab_bar.count() == 0:
return
index = self.tab_bar.tabAt(event.position().toPoint())
if index is not None:
if index == -1:
index = self.tab_bar.count() - 1
self._connect_tab(index)
[docs] def reattach(self, index, tab, text):
"""Reattaches a tab that has been dragged over this window's tab bar.
Args:
index (int): Index in this widget's tab bar where the detached tab has been dragged.
tab (QWidget): The widget in the tab being dragged.
text (str): The title of the tab.
"""
self.tab_widget.insertTab(index, tab, text)
self.tab_widget.setCurrentIndex(index)
self.tab_bar.start_dragging(index)
@Slot()
[docs] def handle_close_request_from_tab(self):
"""Catches close event triggered by a QAction in tab's QToolBar. Calls
QTabWidgets close tabs method to ensure that the last closed tab closes
the editor window."""
self._close_tab(self.tab_widget.currentIndex())
@Slot(int)
[docs] def _close_tab(self, index):
"""Closes the tab at index.
Args:
index (int): tab index
"""
if not self.tab_widget.widget(index).close():
return
self.tab_widget.removeTab(index)
if not self.tab_widget.count():
self.close()
[docs] def set_current_tab(self, tab):
"""Sets the tab that is shown on the window.
Args:
tab (QWidget): tab's contents widget
"""
index = self.tab_widget.indexOf(tab)
if index is None:
return
self.tab_widget.setCurrentIndex(index)
[docs] def restore_ui(self):
"""Restore UI state from previous session."""
self.qsettings.beginGroup(self.settings_group)
window_size = self.qsettings.value("windowSize")
window_pos = self.qsettings.value("windowPosition")
window_state = self.qsettings.value("windowState")
window_maximized = self.qsettings.value("windowMaximized", defaultValue="false")
n_screens = self.qsettings.value("n_screens", defaultValue=1)
self.qsettings.endGroup()
original_size = self.size()
if window_size:
self.resize(window_size)
if window_pos:
self.move(window_pos)
if window_state:
self.restoreState(window_state, version=1) # Toolbar and dockWidget positions
if len(QGuiApplication.screens()) < int(n_screens):
# There are less screens available now than on previous application startup
self.move(0, 0) # Move this widget to primary screen position (0,0)
ensure_window_is_on_screen(self, original_size)
if window_maximized == "true":
self.setWindowState(Qt.WindowMaximized)
[docs] def save_window_state(self):
"""Save window state parameters (size, position, state) via QSettings."""
self.qsettings.beginGroup(self.settings_group)
self.qsettings.setValue("windowSize", self.size())
self.qsettings.setValue("windowPosition", self.pos())
self.qsettings.setValue("windowState", self.saveState(version=1))
self.qsettings.setValue("windowMaximized", self.windowState() == Qt.WindowMaximized)
self.qsettings.setValue("n_screens", len(QGuiApplication.screens()))
self.qsettings.endGroup()
[docs] def closeEvent(self, event):
self._accepting_new_tabs = False
for k in range(self.tab_widget.count()):
editor = self.tab_widget.widget(k)
if not editor.close():
event.ignore()
self._accepting_new_tabs = True
return
self.save_window_state()
super().closeEvent(event)
if event.isAccepted():
other_windows = self._other_editor_windows[type(self).__name__]
try:
window_index = other_windows.index(self)
except ValueError:
# It's possible that closeEvent() is called again after we've already popped
# this window from _other_editor_windows.
return
other_windows.pop(window_index)
[docs]class TabBarPlus(QTabBar):
"""Tab bar that has a plus button floating to the right of the tabs."""
[docs] plus_clicked = Signal()
def __init__(self, parent):
"""
Args:
parent (MultiSpineDBEditor)
"""
super().__init__(parent)
self._parent = parent
self._plus_button = QToolButton(self)
self._plus_button.setIcon(QIcon(CharIconEngine("\uf067")))
self._plus_button.clicked.connect(lambda _=False: self.plus_clicked.emit())
self._move_plus_button()
self.setShape(QTabBar.Shape.RoundedNorth)
self.setTabsClosable(True)
self.setMovable(True)
self.setElideMode(Qt.ElideLeft)
self.drag_index = None
self._tab_hot_spot_x = None
self._hot_spot_y = None
[docs] def resizeEvent(self, event):
"""Sets the dimension of the plus button. Also, makes the tab bar as wide as the parent."""
super().resizeEvent(event)
self.setFixedWidth(self.parent().width())
self.setMinimumHeight(self.height())
self._move_plus_button()
extent = max(0, self.height() - 2)
self._plus_button.setFixedSize(extent, extent)
self.setExpanding(False)
[docs] def tabLayoutChange(self):
super().tabLayoutChange()
self._move_plus_button()
[docs] def mousePressEvent(self, event):
"""Registers the position of the press, in case we need to detach the tab."""
super().mousePressEvent(event)
event_pos = event.position().toPoint()
tab_rect = self.tabRect(self.tabAt(event_pos))
self._tab_hot_spot_x = event_pos.x() - tab_rect.x()
self._hot_spot_y = event_pos.y() - tab_rect.y()
[docs] def mouseMoveEvent(self, event):
"""Detaches a tab either if the user moves beyond the limits of the tab bar, or if it's the only one."""
if self.count() > 0:
self._plus_button.hide()
if self.count() == 1:
self._send_release_event(event.position().toPoint())
hot_spot = QPoint(event.position().toPoint().x(), self._hot_spot_y)
self._parent.start_drag(hot_spot)
return
if self.count() > 1 and not self.geometry().contains(event.position().toPoint()):
self._send_release_event(event.position().toPoint())
hot_spot_x = event.position().toPoint().x()
hot_spot = QPoint(hot_spot_x, self._hot_spot_y)
index = self.tabAt(hot_spot)
if index == -1:
index = self.count() - 1
self._parent.detach(index, hot_spot, hot_spot_x - self._tab_hot_spot_x)
return
super().mouseMoveEvent(event)
[docs] def _send_release_event(self, pos):
"""Sends a mouse release event at given position in local coordinates. Called just before detaching a tab.
Args:
pos (QPoint)
"""
self.drag_index = None
release_event = QMouseEvent(QEvent.MouseButtonRelease, pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier)
QApplication.sendEvent(self, release_event)
[docs] def mouseReleaseEvent(self, event):
super().mouseReleaseEvent(event)
self._plus_button.show()
self.update()
self.releaseMouse()
if self.drag_index is not None:
# Pass it to parent
event.ignore()
[docs] def start_dragging(self, index):
"""Stars dragging the given index. This happens when a detached tab is reattached to this bar.
Args:
index (int)
"""
self.drag_index = index
press_pos = self.tabRect(self.drag_index).center()
press_event = QMouseEvent(QEvent.MouseButtonPress, press_pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier)
QApplication.sendEvent(self, press_event)
QApplication.processEvents()
move_pos = self.mapFromGlobal(QCursor.pos())
if self.geometry().contains(move_pos):
move_event = QMouseEvent(QEvent.MouseMove, move_pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier)
QApplication.sendEvent(self, move_event)
self.grabMouse()
[docs] def index_under_mouse(self):
"""Returns the index under the mouse cursor, or None if the cursor isn't over the tab bar.
Used to check for drop targets.
Returns:
int or NoneType
"""
pos = self.mapFromGlobal(QCursor.pos())
if not self.geometry().contains(pos):
return None
index = self.tabAt(pos)
if index == -1:
index = self.count()
return index