BreezeStyleSheets/example/titlebar.py

1652 lines
60 KiB
Python

#!/usr/bin/env python
#
# The MIT License (MIT)
#
# Copyright (c) <2022-Present> <Alex Huszagh>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the 'Software'), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
'''
titlebar
========
A full-featured, custom titlebar for a subwindow in an MDI area. This
uses a frameless window hint with a custom titlebar, and event filter
to capture titlebar and frame events. This example can also be easily applied to a top-level window.
The custom titlebar supports the following:
- Title text
- Title bar with menu, help, min, max, restore, close, shade, and unshade.
- Help, shade, and unshade are optional.
- Menu contains restore, min, max, move, resize, stay on top, and close.
- Custom window minimization.
- Minimized windows can be placed in any corner.
- Windows reposition on resize events to avoid truncating windows.
- Dynamically toggle window state to keep windows above others.
- Drag titlebar to move window
- Double click titlebar to change window state.
- Restores if maximized or minimized.
- Shades or unshades if in normal state and applicable.
- Otherwise, maximizes window.
- Context menu move and resize events.
- Click "Size" to resize from the bottom right based on cursor.
- Click "Move" to move bottom-center of titlebar to cursor.
- Drag to resize on window border with or without size grips.
- If the window contains size grips, use the default behavior.
- Otherwise, monitor mouse and hover events on window border.
- If hovering over window border, draw appropriate resize cursor.
- If clicked on window border, enter resize mode.
- Click again to exit resize mode.
- Custom border width for a window outline.
The following Qt properties ensure proper styling of the UI:
- `isTitlebar`: should be set on the title bar. ensures all widgets
in the title bar have the correct background.
- `isWindow`: set on the window to ensure there is no default border.
- `hasWindowFrame`: set on a window with a border to draw the frame.
The widget choice is very deliberate: any modifications can cause
unexpected changes. `TitleBar` must be a `QFrame` so the background
is filled, but must have a `NoFrame` shape. The window frame should
have `NoFrame` without a border, but should be a `Box` with a border.
Any other more elaborate style, like a `Panel`, won't be rendered
correctly.
'''
import enum
import shared
import sys
from pathlib import Path
parser = shared.create_parser()
parser.add_argument(
'--minimize-location',
help='location to minimize windows to in the MDI area',
default='BottomLeft',
choices=['TopLeft', 'TopRight', 'BottomLeft', 'BottomRight'],
)
parser.add_argument(
'--border-width',
help='width of the subwindow borders',
type=int,
choices=range(0, 6),
default=1,
)
args, unknown = shared.parse_args(parser)
QtCore, QtGui, QtWidgets = shared.import_qt(args)
compat = shared.get_compat_definitions(args)
colors = shared.get_colors(args, compat)
ICON_MAP = shared.get_icon_map(args, compat)
# 100ms between repaints, so we avoid over-repainting.
# Allows us to avoid glitchy motion during drags/
REPAINT_TIMER = 100
CLICK_TIMER = 20
# Make the titlebar size too large, so we can get the real value with min.
TITLEBAR_HEIGHT = 2**16
class MinimizeLocation(enum.IntEnum):
'''Location where to place minimized widgets.'''
TopLeft = 0
TopRight = 1
BottomLeft = 2
BottomRight = 3
class WindowEdge(enum.IntEnum):
'''Enumerations for window edge positions.'''
NoEdge = 0
Top = 1
Bottom = 2
Left = 3
Right = 4
TopLeft = 5
TopRight = 6
BottomLeft = 7
BottomRight = 8
MINIMIZE_LOCATION = getattr(MinimizeLocation, args.minimize_location)
def standard_icon(widget, icon):
'''Get a standard icon.'''
return shared.standard_icon(args, widget, icon, ICON_MAP)
def menu_icon(widget):
'''Get the menu icon depending on the stylesheet.'''
return standard_icon(widget, compat.SP_TitleBarMenuButton)
def minimize_icon(widget):
'''Get the minimize icon depending on the stylesheet.'''
return standard_icon(widget, compat.SP_TitleBarMinButton)
def maximize_icon(widget):
'''Get the maximize icon depending on the stylesheet.'''
return standard_icon(widget, compat.SP_TitleBarMaxButton)
def restore_icon(widget):
'''Get the restore icon depending on the stylesheet.'''
return standard_icon(widget, compat.SP_TitleBarNormalButton)
def help_icon(widget):
'''Get the help icon depending on the stylesheet.'''
return standard_icon(widget, compat.SP_TitleBarContextHelpButton)
def shade_icon(widget):
'''Get the shade icon depending on the stylesheet.'''
return standard_icon(widget, compat.SP_TitleBarShadeButton)
def unshade_icon(widget):
'''Get the unshade icon depending on the stylesheet.'''
return standard_icon(widget, compat.SP_TitleBarUnshadeButton)
def close_icon(widget):
'''Get the close icon depending on the stylesheet.'''
return standard_icon(widget, compat.SP_TitleBarCloseButton)
def transparent_icon(widget):
'''Create a transparent icon.'''
return QtGui.QIcon()
def action(text, parent=None, icon=None, checkable=None):
'''Create a custom QAction.'''
value = compat.QAction(text, parent)
if icon is not None:
value.setIcon(icon)
if checkable is not None:
value.setCheckable(checkable)
return value
def size_greater(x, y):
'''Compare 2 sizes, determining if any bounds of x are greater than y.'''
return x.width() > y.width() or x.height() > y.height()
def size_less(x, y):
'''Compare 2 sizes, determining if any bounds of x are less than y.'''
return x.width() < y.width() or x.height() < y.height()
# UI WIDGETS
# These are just to populate the views: these could be anything.
class LargeTable(QtWidgets.QTableWidget):
'''Table with a large number of elements.'''
def __init__(self, parent=None):
super().__init__(parent)
self.setColumnCount(100)
self.setRowCount(100)
for index in range(100):
row = QtWidgets.QTableWidgetItem(f'Row {index + 1}')
self.setVerticalHeaderItem(index, row)
column = QtWidgets.QTableWidgetItem(f'Column {index + 1}')
self.setHorizontalHeaderItem(index, column)
class SortableTree(QtWidgets.QTreeWidget):
'''Tree with checkboxes and a sort indicator on the header.'''
def __init__(self, parent=None):
super().__init__(parent)
self.item0 = QtWidgets.QTreeWidgetItem(self)
self.item1 = QtWidgets.QTreeWidgetItem(self)
self.item2 = QtWidgets.QTreeWidgetItem(self.item1)
self.item2.setText(0, 'subitem')
self.item3 = QtWidgets.QTreeWidgetItem(self.item2, ['Row 2.1'])
self.item3.setFlags(self.item3.flags() | compat.ItemIsUserCheckable)
self.item3.setCheckState(0, compat.Unchecked)
self.item4 = QtWidgets.QTreeWidgetItem(self.item2, ['Row 2.2'])
self.item5 = QtWidgets.QTreeWidgetItem(self.item4, ['Row 2.2.1'])
self.item6 = QtWidgets.QTreeWidgetItem(self.item5, ['Row 2.2.1.1'])
self.item7 = QtWidgets.QTreeWidgetItem(self.item5, ['Row 2.2.1.2'])
self.item3.setFlags(self.item7.flags() | compat.ItemIsUserCheckable)
self.item7.setCheckState(0, compat.Checked)
self.item8 = QtWidgets.QTreeWidgetItem(self.item2, ['Row 2.3'])
self.item8.setFlags(self.item8.flags() | compat.ItemIsUserTristate)
self.item8.setCheckState(0, compat.PartiallyChecked)
self.item9 = QtWidgets.QTreeWidgetItem(self, ['Row 3'])
self.item10 = QtWidgets.QTreeWidgetItem(self.item9, ['Row 3.1'])
self.item11 = QtWidgets.QTreeWidgetItem(self, ['Row 4'])
self.headerItem().setText(0, 'qdz')
self.setSortingEnabled(False)
self.topLevelItem(0).setText(0, 'qzd')
self.topLevelItem(1).setText(0, 'effefe')
self.setSortingEnabled(True)
class SettingTabs(QtWidgets.QTabWidget):
'''Sample setting widget with a tab view.'''
def __init__(self, parent=None):
super().__init__(parent)
self.setTabPosition(compat.North)
self.general = QtWidgets.QWidget()
self.addTab(self.general, 'General')
self.addTab(QtWidgets.QWidget(), 'Colors')
self.general_layout = QtWidgets.QGridLayout(self.general)
self.general_layout.setColumnStretch(3, 10)
for row in range(1, 10):
self.general_layout.setRowStretch(row, 1)
self.general_layout.setRowStretch(7, 10)
# Add the data folder hboxlayout
self.general_layout.addWidget(QtWidgets.QLabel('Data Folder'), 0, 0)
self.data_folder = QtWidgets.QLineEdit(str(Path.home()))
self.general_layout.addWidget(self.data_folder, 0, 1, 1, 3)
self.file_dialog = QtWidgets.QPushButton('...', checkable=False)
self.general_layout.addWidget(self.file_dialog, 0, 4)
self.file_dialog.clicked.connect(self.launch_filedialog)
# Add default font.
app = QtWidgets.QApplication.instance()
self.general_layout.addWidget(QtWidgets.QLabel('Default Font'), 1, 0)
self.font_value = QtWidgets.QLineEdit(app.font().family())
self.general_layout.addWidget(self.font_value, 1, 1, 1, 3)
self.font_dialog = QtWidgets.QPushButton('...', checkable=False)
self.general_layout.addWidget(self.font_dialog, 1, 4)
self.font_dialog.clicked.connect(lambda _: self.launch_fontdialog(self.font_value))
# Add item label font
self.general_layout.addWidget(QtWidgets.QLabel('Item Label Font'), 2, 0)
self.item_label_value = QtWidgets.QLineEdit(app.font().family())
self.general_layout.addWidget(self.item_label_value, 2, 1, 1, 3)
self.item_label_dialog = QtWidgets.QPushButton('...', checkable=False)
self.general_layout.addWidget(self.item_label_dialog, 2, 4)
self.item_label_dialog.clicked.connect(lambda _: self.launch_fontdialog(self.item_label_value))
# Add the "Show Grid" QCheckbox.
self.grid = QtWidgets.QCheckBox('Show grid', self.general)
self.general_layout.addWidget(self.grid, 3, 2, 1, 1)
# Grid square size.
self.grid_size = QtWidgets.QLabel('Grid Square Size', self.general)
self.general_layout.addWidget(self.grid_size, 4, 0, 1, 2)
self.grid_spin = QtWidgets.QSpinBox(self.general)
self.grid_spin.setValue(16)
self.general_layout.addWidget(self.grid_spin, 4, 2, 1, 1)
# Add units of measurement
self.units = QtWidgets.QLabel('Default length unit of measurement', self.general)
self.general_layout.addWidget(self.units, 5, 0, 1, 2)
self.units_combo = QtWidgets.QComboBox()
self.units_combo.addItem('Inches')
self.units_combo.addItem('Foot')
self.units_combo.addItem('Meter')
self.general_layout.addWidget(self.units_combo, 5, 2, 1, 1)
# Add the alignment options
self.align_combo = QtWidgets.QComboBox()
self.align_combo.addItem('Align Top')
self.align_combo.addItem('Align Bottom')
self.align_combo.addItem('Align Left')
self.align_combo.addItem('Align Right')
self.align_combo.addItem('Align Center')
self.general_layout.addWidget(self.align_combo, 6, 0, 1, 2)
self.word_wrap = QtWidgets.QCheckBox('Word Wrap', self.general)
self.general_layout.addWidget(self.word_wrap, 6, 2, 1, 1)
def launch_filedialog(self):
'''Launch the file dialog and store the folder.'''
dialog = QtWidgets.QFileDialog()
dialog.setFileMode(compat.Directory)
dialog.setOption(compat.FileDontUseNativeDialog)
dialog.setDirectory(self.data_folder.text())
if shared.execute(args, dialog):
self.data_folder.setText(dialog.selectedFiles()[0])
def launch_fontdialog(self, edit):
initial = QtGui.QFont()
initial.setFamily(edit.text())
font, ok = QtWidgets.QFontDialog.getFont(initial)
if ok:
edit.setText(font.family())
# WINDOW WIDGETS
class Label(QtWidgets.QLabel):
'''Custom QLabel-like class that allows text elision.'''
def __init__(
self,
text='',
parent=None,
elide=compat.ElideNone,
width_cb=None,
):
super().__init__(text, parent)
self._text = text
self._elide = elide
self._width_cb = width_cb
self._timer = QtCore.QTimer()
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.elide)
def text(self):
'''Get the internal text for the label.'''
return self._text
def setText(self, text):
'''Override the set text event to store the text internally.'''
# Need to set the text first, otherwise
# the `width()` might be too small.
self._text = text
super().setText(text)
self.elide()
def elideMode(self):
'''Get the elide mode for the label.'''
return self._elide
def setElideMode(self, elide):
self._elide = elide
def elide(self):
'''Elide the text in the QLabel.'''
# The width estimate might not be valid: check the callback.
width = self.width()
if self._width_cb is not None:
width = self._width_cb()
metrics = QtGui.QFontMetrics(self.font())
elided = metrics.elidedText(self._text, self._elide, width)
super().setText(elided)
class TitleButton(QtWidgets.QToolButton):
'''An icon-only button, without borders, for the titlebar.'''
def __init__(self, icon, parent=None):
super().__init__()
self.setIcon(icon)
self.setAutoRaise(True)
class Titlebar(QtWidgets.QFrame):
'''Custom instance of a QTitlebar'''
def __init__(self, subwindow, parent=None, flags=None):
super().__init__(parent)
# Get and set some properties.
self.setProperty('isTitlebar', True)
self._subwindow = subwindow
self._state = compat.WindowNoState
self._subwindow_rect = None
self._has_help = False
self._has_shade = False
self._is_shaded = False
self._has_shown = False
self._title_column = None
self._move_timer = QtCore.QTimer()
self._move_timer.setSingleShot(True)
self._move_timer.timeout.connect(self.menu_move)
self._size_timer = QtCore.QTimer()
self._size_timer.setSingleShot(True)
self._size_timer.timeout.connect(self.menu_size)
if flags is not None:
self._has_help = bool(flags & compat.WindowContextHelpButtonHint)
self._has_shade = bool(flags & compat.WindowShadeButtonHint)
# Create our widgets.
self._layout = QtWidgets.QGridLayout(self)
self._menu = TitleButton(menu_icon(self))
self._title = Label('', self, compat.ElideRight, self.title_width)
self._min = TitleButton(minimize_icon(self))
self._max = TitleButton(maximize_icon(self))
self._restore= TitleButton(restore_icon(self))
self._close = TitleButton(close_icon(self))
if self._has_help:
self._help = TitleButton(help_icon(self))
if self._has_shade:
self._shade = TitleButton(shade_icon(self))
self._unshade = TitleButton(unshade_icon(self))
# Add actions to our menu.
self._menu.setPopupMode(compat.InstantPopup)
self._main_menu = menu = QtWidgets.QMenu(self)
self._restore_action = action('&Restore', self, restore_icon(self))
self._restore_action.triggered.connect(self.restore)
self._move_action = action('&Move', self, transparent_icon(self))
self._move_action.triggered.connect(self.move_timer)
self._size_action = action('&Size', self, transparent_icon(self))
self._size_action.triggered.connect(self.size_timer)
self._min_action = action('Mi&nimize', self, minimize_icon(self))
self._min_action.triggered.connect(self.minimize)
self._max_action = action('Ma&ximize', self, maximize_icon(self))
self._max_action.triggered.connect(self.maximize)
self._top_action = action('Stay on &Top', self, checkable=True)
self._top_action.toggled.connect(self.toggle_keep_above)
self._close_action = action('&Close', self, close_icon(self))
self._close_action.triggered.connect(self._subwindow.close)
self._main_menu.addActions([
self._restore_action,
self._move_action,
self._size_action,
self._min_action,
self._max_action,
self._top_action,
])
self._main_menu.addSeparator()
self._main_menu.addAction(self._close_action)
self._menu.setMenu(self._main_menu)
# Customize the enabled items.
self._restore_action.setEnabled(False)
# Create our layout.
col = 0
self._layout.addWidget(self._menu, 0, col)
col += 1
self._layout.addWidget(self._title, 0, col, compat.AlignHCenter)
self._layout.setColumnStretch(col, 1)
self._title_column = col
col += 1
if self._has_help:
self._layout.addWidget(self._help, 0, col)
col += 1
self._layout.addWidget(self._min, 0, col)
col += 1
self._layout.addWidget(self._max, 0, col)
col += 1
if self._has_shade:
self._layout.addWidget(self._shade, 0, col)
col += 1
self._layout.addWidget(self._close, 0, col)
self._restore.hide()
if self._has_shade:
self._unshade.hide()
# Add in our event triggers.
self._min.clicked.connect(self.minimize)
self._max.clicked.connect(self.maximize)
self._restore.clicked.connect(self.restore)
self._close.clicked.connect(self._subwindow.close)
if self._has_help:
self._help.clicked.connect(self.help)
if self._has_shade:
self._shade.clicked.connect(self.shade)
self._unshade.clicked.connect(self.unshade)
# PROPERTIES
@property
def minimum_width(self):
'''Get the height (in pixels) for the minimum title bar width.'''
app = QtWidgets.QApplication.instance()
icon_width = self._menu.iconSize().width()
font_size = app.font().pointSizeF()
# We can have 4-6 icons, which with padding means we need
# room for at least 10 characters.
return 6 * icon_width + int(16 * font_size)
@property
def minimum_height(self):
'''Get the height (in pixels) for the minimum title bar height.'''
return TITLEBAR_HEIGHT
@property
def minimum_size(self):
'''Get the minimum dimensions for the title bar.'''
return QtCore.QSize(self.minimum_width, self.minimum_height)
def title_width(self):
'''Get the width of the title based on the grid layout.'''
return self._layout.cellRect(0, self._title_column).width()
# QT-LIKE PROPERTIES
def windowTitle(self):
'''Get the titlebar's window title.'''
return self._title.text()
def setWindowTitle(self, title):
'''Get the titlebar's window title.'''
self._title.setText(title)
def isNormal(self):
'''Get if the titlebar and therefore subwindow has no state.'''
return self._state == compat.WindowNoState
def isMinimized(self):
'''Get if the titlebar and therefore subwindow is minimized.'''
return self._state == compat.WindowMinimized
def isMaximized(self):
'''Get if the titlebar and therefore subwindow is maximized.'''
return self._state == compat.WindowMaximized
# QT EVENTS
def showEvent(self, event):
'''Set the minimum size policies once the widgets are shown.'''
global TITLEBAR_HEIGHT
if not self._has_shown:
TITLEBAR_HEIGHT = min(self.height(), TITLEBAR_HEIGHT)
self._has_shown = True
# Set some size policies.
self.setMinimumSize(self.minimum_width, self.minimum_height)
super().showEvent(event)
# ACTIONS
def set_minimum_size(self):
'''Set the minimum size of the titlebar.'''
self.setMinimumSize(self.minimum_width, self.minimum_height)
def move_timer(self):
'''Start timer to invoke menu_move.'''
# We use a timer since the clicks on the menu can invoke the
# MousePressEvent, which instantly cancels the move event.
self._move_timer.start(CLICK_TIMER)
def menu_move(self):
'''Start a manually trigger move.'''
self.window().start_move(self)
def menu_move_to(self, global_position):
'''
Move the subwindow so that the position is in the center bottom
of the title bar. The position is given in global coordinates.
'''
# Move it so the position is right below the bottom and the center.
position = self.mapFromGlobal(global_position)
rect = self.geometry()
x = position.x() - rect.width() // 2
y = position.y()
rect.moveBottomLeft(QtCore.QPoint(x, y))
window = self._subwindow
window.move_to(window.mapToParent(rect.topLeft()))
def size_timer(self):
'''Start timer to invoke menu_size.'''
# We use a timer since the clicks on the menu can invoke the
# MousePressEvent, which instantly cancels the size event.
self._size_timer.start(CLICK_TIMER)
def menu_size(self):
'''Start a manually triggered resize event.'''
self.window().start_resize(self)
def menu_size_to(self, global_position):
'''
Size the subwindow so that the position is in the center bottom
of the title bar. The position is given in global coordinates.
'''
window = self._subwindow
position = self.mapFromGlobal(global_position)
rect = window.geometry()
rect.setBottomRight(window.mapToParent(position))
window.resize(rect.size())
# Ensure we trigger the elide resize timer.
self._title._timer.start(REPAINT_TIMER)
def minimize(self):
'''Minimize the current subwindow.'''
if self.isNormal():
self._subwindow_rect = self._subwindow.geometry()
self.set_minimized()
self.set_shaded()
# Toggle state
self._state = compat.WindowMinimized
self._is_shaded = False
self._subwindow.minimize(self._subwindow.minimized_size)
# Toggle the menu actions
# Minimized windows should not be movable, resizable, or minimizable.
self._restore_action.setEnabled(True)
self._move_action.setEnabled(False)
self._size_action.setEnabled(False)
self._min_action.setEnabled(False)
self._max_action.setEnabled(True)
self._subwindow.mdiArea().minimize(self._subwindow)
def maximize(self):
'''Maximize the current subwindow.'''
if self.isNormal():
self._subwindow_rect = self._subwindow.geometry()
elif self.isMinimized() and not self._is_shaded:
self._subwindow.mdiArea().unminimize(self._subwindow)
size = self._subwindow.maximum_size
rect = QtCore.QRect(0, 0, size.width(), size.height())
self.set_maximized()
self.set_unshaded()
# Toggle state
self._state = compat.WindowMaximized
self._is_shaded = False
self._subwindow.maximize(rect)
# Toggle the menu actions
self._restore_action.setEnabled(True)
self._move_action.setEnabled(False)
self._size_action.setEnabled(False)
self._min_action.setEnabled(True)
self._max_action.setEnabled(False)
def restore(self):
'''Restore the current subwindow (set to no state).'''
if self.isMinimized() and not self._is_shaded:
self._subwindow.mdiArea().unminimize(self._subwindow)
self.set_restored()
self.set_unshaded()
# Toggle state
self._state = compat.WindowNoState
self._is_shaded = False
self._subwindow.restore(self._subwindow_rect)
# Toggle the menu actions
self._restore_action.setEnabled(False)
self._move_action.setEnabled(True)
self._size_action.setEnabled(True)
self._min_action.setEnabled(True)
self._max_action.setEnabled(True)
def shade(self):
'''Shade the current subwindow.'''
# Shaded windows are treated as if they have minimized state, and
# if the window is maximized, it sets the previous subwindow rect
# to the maximized geometry.
self.set_shaded()
self.set_minimized()
# Toggle state
self._state = compat.WindowMinimized
self._is_shaded = True
self._subwindow_rect = self._subwindow.geometry()
width = self._subwindow.width()
height = self._subwindow.minimized_size.height()
self._subwindow.shade(QtCore.QSize(width, height))
# Toggle the menu actions
# Shaded windows should be movable, but not resizable or minimizable.
self._restore_action.setEnabled(True)
self._move_action.setEnabled(True)
self._size_action.setEnabled(False)
self._min_action.setEnabled(False)
self._max_action.setEnabled(True)
def unshade(self):
'''Unshade the current subwindow.'''
if self.isMinimized() and not self._is_shaded:
self._subwindow.mdiArea().unminimize(self._subwindow)
# If the window is minimized, it restores to the previous
# window state and position.
self.set_unshaded()
self.set_restored()
# Toggle state
self._state = compat.WindowNoState
self._is_shaded = False
self._subwindow.unshade(self._subwindow_rect)
# Toggle the menu actions
# Unshaded windows have no state: they are restored.
self._restore_action.setEnabled(False)
self._move_action.setEnabled(True)
self._size_action.setEnabled(True)
self._min_action.setEnabled(True)
self._max_action.setEnabled(True)
def toggle_keep_above(self, checked):
'''Toggle whether to keep the window above others.'''
self._subwindow.setWindowFlag(compat.WindowStaysOnTopHint, checked)
def help(self):
'''Enter what's this mode.'''
QtWidgets.QWhatsThis.enterWhatsThisMode()
# VIEW
def set_minimized(self):
'''Show the restore and maximize icons.'''
if self.isNormal():
# Restore hidden, minimize + maximize shown
self._layout.replaceWidget(self._min, self._restore)
self._restore.show()
self._min.hide()
elif self.isMinimized():
return
else:
# Maximize hidden, minimize + restore shown
self._layout.replaceWidget(self._restore, self._max)
self._layout.replaceWidget(self._min, self._restore)
self._max.show()
self._min.hide()
def set_maximized(self):
'''Show the minimize and restore icons.'''
if self.isNormal():
# Restore hidden, minimize + maximize shown
self._layout.replaceWidget(self._max, self._restore)
self._restore.show()
self._max.hide()
elif self.isMinimized():
# Minimize hidden, restore + maximize shown
self._layout.replaceWidget(self._restore, self._min)
self._layout.replaceWidget(self._max, self._restore)
self._min.show()
self._max.hide()
def set_restored(self):
'''Show the minimize and maximize icons.'''
if self.isNormal():
return
elif self.isMinimized():
# Minimize hidden, restore + maximize shown
self._layout.replaceWidget(self._restore, self._min)
self._min.show()
self._restore.hide()
else:
# Maximize hidden, minimize + restore shown
self._layout.replaceWidget(self._restore, self._max)
self._max.show()
self._restore.hide()
def set_shaded(self):
'''Show the unshade icon (and hide the shade icon).'''
if self._has_shade and not (self.isMinimized() or self._is_shaded):
self._layout.replaceWidget(self._shade, self._unshade)
self._shade.hide()
self._unshade.show()
def set_unshaded(self):
'''Show the shade icon (and hide the unshade icon).'''
if self._has_shade and (self.isMinimized() or self._is_shaded):
self._layout.replaceWidget(self._unshade, self._shade)
self._unshade.hide()
self._shade.show()
class SizeFrame(QtCore.QObject):
'''An invisible frame for resizing events around a window.'''
def __init__(self, window=None, border_width=3):
super().__init__(window)
self._window = window
self._border_width = border_width
self._band = QtWidgets.QRubberBand(compat.RubberBandRectangle)
self._pressed = False
self._cursor = None
self._press_edge = WindowEdge.NoEdge
self._move_edge = WindowEdge.NoEdge
self._window.setMouseTracking(True)
self._window.setWindowFlag(compat.FramelessWindowHint, True)
self._window.setAttribute(compat.WA_Hover)
@property
def is_active(self):
'''Get if the SizeFrame resize event is active.'''
return self._pressed
def is_on_top(self, pos, rect):
'''Determine if the cursor is on the top of the widget.'''
return (
pos.x() >= rect.x() + self._border_width and
pos.x() <= rect.x() + rect.width() - self._border_width and
pos.y() >= rect.y() and
pos.y() <= rect.y() + self._border_width
)
def is_on_bottom(self, pos, rect):
'''Determine if the cursor is on the bottom of the widget.'''
return (
pos.x() >= rect.x() + self._border_width and
pos.x() <= rect.x() + rect.width() - self._border_width and
pos.y() >= rect.y() + rect.height() - self._border_width and
pos.y() <= rect.y() + rect.height()
)
def is_on_left(self, pos, rect):
'''Determine if the cursor is on the left of the widget.'''
return (
pos.x() >= rect.x() - self._border_width and
pos.x() <= rect.x() + self._border_width and
pos.y() >= rect.y() + self._border_width and
pos.y() <= rect.y() + rect.height() - self._border_width
)
def is_on_right(self, pos, rect):
'''Determine if the cursor is on the right of the widget.'''
return (
pos.x() >= rect.x() + rect.width() - self._border_width and
pos.x() <= rect.x() + rect.width() and
pos.y() >= rect.y() + self._border_width and
pos.y() <= rect.y() + rect.height() - self._border_width
)
def is_on_top_left(self, pos, rect):
'''Determine if the cursor is on the top left of the widget.'''
return (
pos.x() >= rect.x() and
pos.x() <= rect.x() + self._border_width and
pos.y() >= rect.y() and
pos.y() <= rect.y() + self._border_width
)
def is_on_top_right(self, pos, rect):
'''Determine if the cursor is on the top right of the widget.'''
return (
pos.x() >= rect.x() + rect.width() - self._border_width and
pos.x() <= rect.x() + rect.width() and
pos.y() >= rect.y() and
pos.y() <= rect.y() + self._border_width
)
def is_on_bottom_left(self, pos, rect):
'''Determine if the cursor is on the bottom left of the widget.'''
return (
pos.x() >= rect.x() and
pos.x() <= rect.x() + self._border_width and
pos.y() >= rect.y() + rect.height() - self._border_width and
pos.y() <= rect.y() + rect.height()
)
def is_on_bottom_right(self, pos, rect):
'''Determine if the cursor is on the bottom right of the widget.'''
return (
pos.x() >= rect.x() + rect.width() - self._border_width and
pos.x() <= rect.x() + rect.width() and
pos.y() >= rect.y() + rect.height() - self._border_width and
pos.y() <= rect.y() + rect.height()
)
def cursor_position(self, pos, rect):
'''Calculate the cursor position inside the window.'''
if self.is_on_left(pos, rect):
return WindowEdge.Left
elif self.is_on_right(pos, rect):
return WindowEdge.Right
elif self.is_on_bottom(pos, rect):
return WindowEdge.Bottom
elif self.is_on_top(pos, rect):
return WindowEdge.Top
elif self.is_on_bottom_left(pos, rect):
return WindowEdge.BottomLeft
elif self.is_on_bottom_right(pos, rect):
return WindowEdge.BottomRight
elif self.is_on_top_right(pos, rect):
return WindowEdge.TopRight
elif self.is_on_top_left(pos, rect):
return WindowEdge.TopLeft
return WindowEdge.NoEdge
def top_left(self):
'''Get the top/left position of the window in global coordinates.'''
# Calculate the top left bounds of our window to get our frame.
# We want our frame in global coordinates, but our window
# might be a subwindow. If it has a parent, then it's a subwindow
# and we need to map our coordinates.
point = QtCore.QPoint(self._window.x(), self._window.y())
if self._window.parent() is not None:
point = self._window.parent().mapToGlobal(point)
return point
def frame_geometry(self):
'''Calculate the frame geometry of our window in global coordinates.'''
return QtCore.QRect(self.top_left(), self._window.frameSize())
def update_cursor(self, position):
'''Update the cursor shape depending on the cursor position.'''
if self._window.isMaximized() or self._window.isFullScreen():
self.unset_cursor()
return
if self._pressed:
return
rect = self.frame_geometry()
self._move_edge = self.cursor_position(position, rect)
if self._move_edge == WindowEdge.NoEdge:
self.unset_cursor()
return
if self._move_edge in (WindowEdge.Top, WindowEdge.Bottom):
self._cursor = compat.SizeVerCursor
elif self._move_edge in (WindowEdge.Left, WindowEdge.Right):
self._cursor = compat.SizeHorCursor
elif self._move_edge in (WindowEdge.TopLeft, WindowEdge.BottomRight):
self._cursor = compat.SizeFDiagCursor
elif self._move_edge in (WindowEdge.TopRight, WindowEdge.BottomLeft):
self._cursor = compat.SizeBDiagCursor
self._window.setCursor(self._cursor)
def unset_cursor(self):
'''Unset the custom cursor.'''
if self._cursor:
self._window.unsetCursor()
self._cursor = None
def enter(self, event):
'''Handle the enterEvent of the window.'''
position = shared.single_point_position(args, event)
self.update_cursor(self._window.mapToGlobal(position))
def leave(self, event):
'''Handle the leaveEvent of the window.'''
if not self._pressed:
self.unset_cursor()
def mouse_move(self, event):
'''Handle the mouseMoveEvent of the window.'''
position = shared.single_point_global_position(args, event)
if not self._pressed:
self.update_cursor(position)
return
# Get our new frame dimensions.
rect = self._band.frameGeometry()
if self._press_edge == WindowEdge.NoEdge:
return
elif self._press_edge == WindowEdge.Top:
rect.setTop(position.y())
elif self._press_edge == WindowEdge.Bottom:
rect.setBottom(position.y())
elif self._press_edge == WindowEdge.Left:
rect.setLeft(position.x())
elif self._press_edge == WindowEdge.Right:
rect.setRight(position.x())
elif self._press_edge == WindowEdge.TopLeft:
rect.setTopLeft(position)
elif self._press_edge == WindowEdge.TopRight:
rect.setTopRight(position)
elif self._press_edge == WindowEdge.BottomLeft:
rect.setBottomLeft(position)
elif self._press_edge == WindowEdge.BottomRight:
rect.setBottomRight(position)
if rect.width() < self._window.minimumWidth():
rect.setLeft(self._window.x())
if rect.height() < self._window.minimumHeight():
rect.setTop(self._window.y())
self._window.setGeometry(rect)
self._band.setGeometry(rect)
def mouse_press(self, event):
'''Handle the mousePressEvent of the window.'''
if event.button() == compat.LeftButton:
position = shared.single_point_global_position(args, event)
rect = self.frame_geometry()
self._press_edge = self.cursor_position(position, rect)
# We want to separately hand drags, so only
# set this if we are pressing on the edge.
if self._press_edge != WindowEdge.NoEdge:
self._pressed = True
self._band.setGeometry(rect)
def mouse_release(self, event):
'''Handle the mouseReleaseEvent of the window.'''
if event.button() == compat.LeftButton:
self._pressed = False
def hover_move(self, event):
'''Handle the hoverMoveEvent of the window.'''
position = shared.single_point_position(args, event)
self.update_cursor(self._window.mapToGlobal(position))
class SubWindow(QtWidgets.QMdiSubWindow):
'''Custom subwindow instance'''
def __init__(
self,
parent=None,
flags=QtCore.Qt.WindowType(0),
sizegrip=False,
):
super().__init__(parent)
self.setWindowFlags(self.windowFlags() | flags)
super().setWidget(QtWidgets.QWidget())
# Create our widgets. Sizeframe and sizegrip are mutually exclusive.
self._central = QtWidgets.QFrame(super().widget())
self._central.setLayout(QtWidgets.QVBoxLayout())
self._titlebar = Titlebar(self, self._central, flags)
self._widget = QtWidgets.QWidget(self._central)
self._widget.setLayout(QtWidgets.QVBoxLayout())
self._sizeframe = None
self._sizegrip = None
self._border = args.border_width
self._titlebar_size = QtCore.QSize()
self._sizegrip_size = QtCore.QSize()
if sizegrip:
self._sizegrip = QtWidgets.QSizeGrip(self._central)
else:
self._sizeframe = SizeFrame(self, border_width=5)
# Add our titlebar, then our central widgets, etc.
# Make sure we have our titlebar compacted to fit.
# The trick here is quite simple: have no spacing
# on the parent layout (so the titlebar goes on the
# absolute top), and all 3 widgets, with the main
# widget expanding to the view, and make it seem like
# it's the central widget for the layout, as is its layout.
# Align the size grip to the bottom right, without stretch, so
# it compacts and has the natural placement. For the titlebar,
# align it top so when the sizegrip is hidden (as is the widget),
# it does not have a border/padding on the top.
bottom_right = compat.AlignBottom | compat.AlignRight
super().layout().setSpacing(0)
super().layout().addWidget(self._central, 10)
self._central.layout().setSpacing(0)
self._central.layout().addWidget(self._titlebar, 0, compat.AlignTop)
self._central.layout().addWidget(self._widget, 10)
if self._sizegrip is not None:
self._central.layout().addWidget(self._sizegrip, 0, bottom_right)
# Set the border properties.
self._central.layout().setContentsMargins(QtCore.QMargins(0, 0, 0, 0))
self._central.setProperty('isWindow', True)
if self._border > 0:
self._central.setProperty('windowFrame', min(self._border, 5))
self._central.setFrameShape(compat.Box)
self._central.setFrameShadow(compat.Raised)
# Ensure our titlebar gets highest priority.
self._titlebar.raise_()
self._widget.lower()
# PROPERTIES
@property
def border_size(self):
'''Get the size of the border, regardless if present.'''
return QtCore.QSize(2 * self._border, 2 * self._border)
@property
def minimized_content_size(self):
'''Get the minimum content size of the widget.'''
return self._titlebar_size
size = self._titlebar_size
if self._border:
size = size + self.border_size
return size
@property
def minimized_size(self):
'''Get the minimum size of the widget, with the size grips hidden.'''
size = self.minimized_content_size
if self._border:
size = size + self.border_size
return size
@property
def minimum_size(self):
'''Get the minimum size for the widget.'''
size = self.minimized_size
if self._sizegrip is not None and self._sizegrip.isVisible():
# Don't modify in place: percolates later.
size = size + self._sizegrip_size
return size
@property
def maximum_size(self):
'''Get the maximum size for the widget.'''
return self.mdiArea().size()
# RESIZE
def move_to(self, position):
'''Move the window to the desired position'''
# Also updates the stored previous subwindow position, if applicable.
# This means shading/unshading uses the new position of the window,
# but the old sizes, rather than jump the window back.
self.move(position)
rect = self._titlebar._subwindow_rect
if rect is not None:
rect.moveTo(position)
def set_minimum_size(self):
'''Sets the minimum size of the window and the titlebar.'''
self._titlebar.set_minimum_size()
self._titlebar_size = self._titlebar.minimumSize()
self.setMinimumSize(self.minimum_size)
def minimize(self, size):
'''Minimize the window, hiding the main widget and size grip.'''
self._widget.hide()
if self._sizegrip is not None:
self._sizegrip.hide()
self.set_minimum_size()
self.resize(size)
def maximize(self, rect):
'''Maximize the window, showing the main widget and hiding size grip.'''
self._widget.show()
if self._sizegrip is not None:
self._sizegrip.hide()
self.set_minimum_size()
self.setGeometry(rect)
def restore(self, rect):
'''Restore the window, showing the main widget and size grip.'''
self._widget.show()
if self._sizegrip is not None:
self._sizegrip.show()
self.set_minimum_size()
self.setGeometry(rect)
def shade(self, size):
'''Shade the window, hiding the main widget and size grip.'''
self._widget.hide()
if self._sizegrip is not None:
self._sizegrip.hide()
self.set_minimum_size()
self.resize(size)
def unshade(self, rect):
'''Unshade the window, showing the main widget and size grip.'''
self._widget.show()
if self._sizegrip is not None:
self._sizegrip.show()
self.set_minimum_size()
self.setGeometry(rect)
# QT EVENTS
def resizeEvent(self, event):
'''Handle widget resize events here.'''
# Need to trigger the titlebar title resize. Need to handle it
# here, since the SizeFrame resizes won't always trigger a
# Label::resizeEvent, which can cause the text to stay elided.
title_timer = self._titlebar._title._timer
title_timer.start(REPAINT_TIMER)
super().resizeEvent(event)
def showEvent(self, event):
'''Set the minimum size policies once the widgets are shown.'''
# Until shown, the size grip has inaccurate sizes.
# Set the minimum size policy of the widget.
# The show event occurs just after everything is shown,
# so the widget sizes (and isVisible) are accurate.
self._titlebar_size = self._titlebar.minimumSize()
if self._sizegrip is not None:
sizegrip_size = self._sizegrip.sizeHint()
self._sizegrip_size = QtCore.QSize(0, sizegrip_size.height())
self.setMinimumSize(self.minimum_size)
super().showEvent(event)
def mouseDoubleClickEvent(self, event):
'''Override the mouse double click, and don't call the press event.'''
# By default, the flowchart for titlebar double clicks is as follows:
# 1. If minimized, restore
# 2. If maximized, restore
# 3. If no state and can shade, shade
# 4. If no state and cannot shade, maximize
# 5. If shaded, unshade.
widget = self._titlebar
if not widget.underMouse() or event.button() != compat.LeftButton:
return super().mouseDoubleClickEvent(event)
if widget._is_shaded:
widget.unshade()
elif widget.isMinimized() or widget.isMaximized():
widget.restore()
elif widget._has_shade:
widget.shade()
else:
widget.maximize()
def mousePressEvent(self, event):
'''Override a mouse click on the titlebar to allow a move.'''
widget = self._titlebar
window = self.window()
if widget.underMouse():
# `self.window()._move` cannot be set, since we're inside
# the global event filter here. We handle conflicts here,
# so only one of the 4 states can be set. We can't move
# minimized widgets, so don't try.
is_left = event.button() == compat.LeftButton
is_minimized = self.isMinimized() and not self._titlebar._is_shaded
has_frame = window._frame is not None
if is_left and not is_minimized and not has_frame:
self.window().start_drag(event)
elif event.button() == compat.RightButton:
position = shared.single_point_global_position(args, event)
shared.execute(args, widget._main_menu, position)
return super().mousePressEvent(event)
def mouseMoveEvent(self, event):
'''Reposition the window on the move event.'''
window = self.window()
if window._frame is not None:
window.end_drag()
if window._drag is not None:
self.window().handle_drag(self, event)
return super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
'''End the drag event.'''
self.window().end_drag()
return super().mouseReleaseEvent(event)
# QT-LIKE PROPERTIES
def windowTitle(self):
'''Get the window title from the titlebar.'''
return self._titlebar.windowTitle()
def setWindowTitle(self, title):
'''Get the window title from the titlebar.'''
self._titlebar.setWindowTitle(title)
def layout(self):
'''Get the subwindow layout (mapped to self._widget)'''
return self._widget.layout()
def setLayout(self, layout):
'''Set the subwindow layout (mapped to self._widget)'''
self._widget.setLayout(layout)
def widget(self):
'''Get the subwindow widget (mapped to self._widget)'''
return self._widget
def setWidget(self, widget):
'''Set the subwindow widget (mapped to self._widget)'''
super().layout().replaceWidget(self._widget, widget)
self._widget = widget
def isMinimized(self):
'''Overload since we use a custom minimized for our subwindow.'''
return self._titlebar.isMinimized()
def isMaximized(self):
'''Overload since we use a custom maximized for our subwindow.'''
return self._titlebar.isMaximized() or super().isMaximized()
class MdiArea(QtWidgets.QMdiArea):
'''Override the QMdiArea for window minimization and background color.'''
def __init__(self, parent=None, location=MINIMIZE_LOCATION):
super().__init__(parent)
self._minimized = []
self._location = location
self._timer = QtCore.QTimer()
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.move_minimized)
# Set the background color
background = self.background()
background.setColor(colors.ViewBackground)
self.setBackground(background)
def resizeEvent(self, event):
'''Handle moving minimized windows without glitchy motion.'''
self._timer.start(REPAINT_TIMER)
super().resizeEvent(event)
def minimize(self, subwindow):
'''Minimize a subwindow and reposition it.'''
self._minimized.append(subwindow)
self.move_minimized()
def unminimize(self, subwindow):
'''Unminimize a subwindow.'''
self._minimized.remove(subwindow)
self.move_minimized()
def move_minimized(self):
'''Move the minimized windows.'''
# No need to set the geometry of our minimized windows.
if not self._minimized:
return
# Get the geometry of the elements, and calculate the windows per row.
window = self._minimized[0]
has_border = any(i._border for i in self._minimized)
size = window.minimized_content_size
if has_border:
size = size + window.border_size
width = size.width()
height = size.height()
width += max(int(0.01 * width), 1)
height += max(int(0.01 * height), 1)
total_size = self.size()
minimized_count = len(self._minimized)
row_count = max(total_size.width() // width, 1)
rows = minimized_count // row_count
if minimized_count % row_count != 0:
rows += 1
# Get how we shift our elements. Start our elements so
# the first iteration will shift them into place.
# We never want to place elements at a negative index,
# so our right always starts at least at 1.
# For our bottom, we want the last element to be placed at (_, 0)
# if it would overflow to the top, we place it at 0 instead.
left_x = 0
right_x = max(total_size.width() - width, 0)
top_y = 0
bottom_y = max(total_size.height() - height, (rows - 1) * height)
if self._location == MinimizeLocation.TopLeft:
point = QtCore.QPoint(left_x, top_y)
new_column = lambda p: QtCore.QPoint(left_x, p.y() + height)
shift_row = lambda p, w: p + QtCore.QPoint(w, 0)
elif self._location == MinimizeLocation.TopRight:
point = QtCore.QPoint(right_x, top_y)
new_column = lambda p: QtCore.QPoint(right_x, p.y() + height)
shift_row = lambda p, w: p - QtCore.QPoint(w, 0)
elif self._location == MinimizeLocation.BottomLeft:
point = QtCore.QPoint(left_x, bottom_y)
new_column = lambda p: QtCore.QPoint(left_x, p.y() - height)
shift_row = lambda p, w: p + QtCore.QPoint(w, 0)
else:
point = QtCore.QPoint(right_x, bottom_y)
new_column = lambda p: QtCore.QPoint(right_x, p.y() - height)
shift_row = lambda p, w: p - QtCore.QPoint(w, 0)
# Now, need to place them accordingly.
# Need to handle unshifts, if they occur, due to the
for index in range(len(self._minimized)):
# Calculate our new column, only storing if it is a new column.
window = self._minimized[index]
is_new_column = index % row_count == 0
if index != 0 and is_new_column:
point = new_column(point)
window.move(point)
point = shift_row(point, width)
class Window(QtWidgets.QMainWindow):
'''Main window with a custom event filter for all events.'''
def __init__(self, parent=None, flags=QtCore.Qt.WindowType(0)):
super().__init__(parent, flags)
self.centralwidget = QtWidgets.QWidget(self)
self.layout = QtWidgets.QVBoxLayout(self.centralwidget)
self.setCentralWidget(self.centralwidget)
self.resize(1068, 824)
self.setWindowTitle('Custom SubWindow Style.')
flags = compat.SubWindow
flags |= compat.FramelessWindowHint
self.area = MdiArea(self.centralwidget)
self.window1 = SubWindow(flags=flags, sizegrip=True)
self.window1.setWindowTitle('Short Title')
self.area.addSubWindow(self.window1)
self.table = LargeTable(self.window1.widget())
self.window1.layout().addWidget(self.table)
flags = compat.SubWindow
flags |= compat.WindowContextHelpButtonHint
flags |= compat.WindowShadeButtonHint
flags |= compat.FramelessWindowHint
self.window2 = SubWindow(flags=flags)
self.window2.setWindowTitle('Example of a very, very long title')
self.area.addSubWindow(self.window2)
self.tree = SortableTree(self.window2.widget())
self.window2.layout().addWidget(self.tree)
flags = compat.SubWindow
flags |= compat.WindowShadeButtonHint
flags |= compat.FramelessWindowHint
self.window3 = SubWindow(flags=flags, sizegrip=True)
self.window3.setWindowTitle('Medium length title')
self.area.addSubWindow(self.window3)
self.layout.addWidget(self.area)
self.tab = SettingTabs(self.window3.widget())
self.window3.layout().addWidget(self.tab)
# Tracking for move and resize events.
# Click and drag title bar move.
self._drag = None
# Context menu move.
self._move = None
# Context menu resize.
self._resize = None
# SizeFrame resize.
self._frame = None
def set_cursor(self, cursor):
'''Temporarily set the application cursor to the override cursor.'''
app = QtWidgets.QApplication.instance()
app.setOverrideCursor(QtGui.QCursor(cursor))
def restore_cursor(self):
'''Restore the overridden cursor.'''
app = QtWidgets.QApplication.instance()
app.restoreOverrideCursor()
def start_drag(self, event):
'''Start the drag state.'''
self._drag = event.pos()
def handle_drag(self, subwindow, event):
'''Handle the drag event.'''
subwindow.move_to(subwindow.mapToParent(event.pos() - self._drag))
def end_drag(self):
'''End the drag state.'''
self._drag = None
def start_move(self, widget):
'''Start the move state.'''
self._move = widget
self._move.menu_move_to(QtGui.QCursor.pos())
def handle_move(self, obj, event):
'''Handle the move event.'''
position = shared.single_point_global_position(args, event)
self._move.menu_move_to(position)
def end_move(self):
'''End the move state.'''
self._move = None
def start_resize(self, widget):
'''Start the resize state.'''
self._resize = widget
self.set_cursor(compat.SizeFDiagCursor)
self._resize.menu_size_to(QtGui.QCursor.pos())
def handle_resize(self, obj, event):
'''Handle the resize event.'''
position = shared.single_point_global_position(args, event)
self._resize.menu_size_to(position)
def end_resize(self):
'''End the resize state.'''
if self._resize is not None:
self._resize = None
self.restore_cursor()
def start_frame(self, subwindow):
'''Start the frame resize state.'''
self._frame = subwindow
def handle_frame(self, obj, event):
'''Handle the frame resize event.'''
self.window_frame_event(obj, event)
def end_frame(self):
'''End the frame resize state.'''
self._frame = None
def resolve_window_state(self):
'''Handle theoretically possible conflicts in window state.'''
# The _drag, _move, _resize, and _frame options are
# mutually exclusive: only one can be active at a given time.
# Since we use timers for `_move` and `_resize`, it's **possible**
# multiple might be active here, but it's unlikely. So, we handle
# those cases by playing favorites. _frame > _resize > _move > _drag.
if self._frame is not None:
self.end_resize()
if self._resize is not None:
self.end_move()
if self._move is not None:
self.end_drag()
def window_move_event(self, obj, event):
'''Handle window move events.'''
if event.type() == compat.MouseMove:
self.handle_move(obj, event)
elif event.type() == compat.MouseButtonPress:
self.end_move()
def window_resize_event(self, obj, event):
'''Handle window resize events.'''
if event.type() == compat.MouseMove:
self.handle_resize(obj, event)
elif event.type() == compat.MouseButtonPress:
self.end_resize()
def window_frame_event(self, subwindow, event):
'''Handle size adjustments using the window frame.'''
frame = subwindow._sizeframe
# Uses size grips, return early.
if frame is None:
return
# No position for the event: we don't use it.
if event.type() in (compat.Enter, compat.HoverEnter):
frame.enter(event)
elif event.type() in (compat.Leave, compat.HoverLeave):
frame.leave(event)
elif event.type() == compat.MouseMove:
frame.mouse_move(event)
elif event.type() == compat.MouseButtonPress:
frame.mouse_press(event)
elif event.type() == compat.MouseButtonRelease:
frame.mouse_release(event)
elif event.type() == compat.HoverMove:
frame.hover_move(event)
# Store if the frame state is active.
if frame.is_active:
self.start_frame(frame)
else:
self.end_frame()
def eventFilter(self, obj, event):
'''Custom event filter to handle move and resize events.'''
self.resolve_window_state()
if self._move is not None:
# Cannot occur while the size frame is active.
self.window_move_event(obj, event)
elif self._resize is not None:
self.window_resize_event(obj, event)
elif isinstance(obj, SubWindow) and not obj.isMinimized():
self.handle_frame(obj, event)
return super().eventFilter(obj, event)
def enterEvent(self, event):
'''Reset the resize mouse on an enter event.'''
if self._resize is not None:
self.set_cursor(compat.SizeFDiagCursor)
return super().enterEvent(event)
def leaveEvent(self, event):
'''Reset the resize mouse on an enter event.'''
if self._resize is not None:
self.restore_cursor()
return super().leaveEvent(event)
def main():
'Application entry point'
app, window = shared.setup_app(args, unknown, compat, window_class=Window)
app.installEventFilter(window)
shared.set_stylesheet(args, app, compat)
return shared.exec_app(args, app, window, compat)
if __name__ == '__main__':
sys.exit(main())