2269 lines
84 KiB
Python
2269 lines
84 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.
|
|
|
|
NOTE: you cannot correctly emulate a title bar if the desktop environment
|
|
is Wayland, even if the app is running in X11 mode. This mostly affects
|
|
just the top-level title bar (and subwindows almost entirely work),
|
|
but there are a few small issues for subwindows.
|
|
|
|
The top-level title bar can have a few issues on Wayland.
|
|
- Cannot move the window position. This cannot be done even if you know
|
|
the compositor (such as kwin).
|
|
- Cannot use the menu resize due to `QWidget::mouseGrab()`.
|
|
- This plugin supports grabbing the mouse only for popup windows
|
|
- The window stops tracking mouse movements past a certain distance.
|
|
- Attempting to move the window position causes global position to be wrong.
|
|
- Wayland does not support `Stay on Top` directive.
|
|
- qt.qpa.wayland: Wayland does not support QWindow::requestActivate()
|
|
|
|
A few other issues exist on Wayland.
|
|
- The menu resize has to guess the mouse position outside of the window bounds.
|
|
- This cannot be fixed since we cannot use mouse events if the user
|
|
is outside the main window, nor do hover events trigger.
|
|
We cannot guess where the user left the main window, since
|
|
`QCursor::pos` will not be updated until the user moves the
|
|
mouse within the application, so merely resizing until the
|
|
actual cursor is within the window won't work.
|
|
- We cannot intercept mouse events for the menu resize outside the window.
|
|
- This even occurs when forcing X11 on Wayland.
|
|
|
|
On Windows, only the menu resize event fails. For the subwindow, it stops
|
|
tracking outside of the window boundaries, and for the main window, it does
|
|
the same, making it practically useless.
|
|
|
|
# Testing
|
|
|
|
The current platforms/desktop environments have been tested:
|
|
- Gnome (X11, Wayland)
|
|
- KDE Plasma (X11, Wayland)
|
|
- Windows 10
|
|
'''
|
|
|
|
import enum
|
|
import os
|
|
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,
|
|
)
|
|
parser.add_argument(
|
|
'--default-window-frame',
|
|
help='use the default title bars',
|
|
action='store_true',
|
|
)
|
|
parser.add_argument(
|
|
'--status-bar',
|
|
help='use a top-level status bar',
|
|
action='store_true',
|
|
)
|
|
parser.add_argument(
|
|
'--window-help',
|
|
help='add a top-level context help button',
|
|
action='store_true',
|
|
)
|
|
parser.add_argument(
|
|
'--window-shade',
|
|
help='add a top-level shade/unshade button',
|
|
action='store_true',
|
|
)
|
|
parser.add_argument(
|
|
'--wayland-testing',
|
|
help='debug with a custom titlebar on wayland',
|
|
action='store_true',
|
|
)
|
|
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
|
|
TRACK_TIMER = 20
|
|
CLICK_TIMER = 20
|
|
# Make the titlebar size too large, so we can get the real value with min.
|
|
TITLEBAR_HEIGHT = 2**16
|
|
# QWIDGETSIZE_MAX isn't exported, which is needed to remove fixedSize constraints.
|
|
QWIDGETSIZE_MAX = (1 << 24) - 1
|
|
|
|
# Determine the Linux display server protocol we're using.
|
|
# Use `XDG_SESSION_TYPE`, since we can override it for X11.
|
|
IS_WAYLAND = os.environ.get('XDG_SESSION_TYPE') == 'wayland'
|
|
IS_XWAYLAND = os.environ.get('XDG_SESSION_TYPE') == 'xwayland'
|
|
IS_X11 = os.environ.get('XDG_SESSION_TYPE') == 'x11'
|
|
# We can run X11 on Wayland, but this doesn't support certain
|
|
# features like mouse grabbing, so we don't use it here.
|
|
IS_TRUE_WAYLAND = 'WAYLAND_DISPLAY' in os.environ
|
|
USE_WAYLAND_FRAME = IS_WAYLAND and not args.wayland_testing
|
|
|
|
# Add a warning if we're using Wayland with a custom titlebar.
|
|
if not args.default_window_frame and USE_WAYLAND_FRAME:
|
|
print('WARNING: Wayland does not support custom title bars.', file=sys.stderr)
|
|
print('Applications in Wayland cannot set their own position.', file=sys.stderr)
|
|
print('Defaulting to the system title bar instead.', file=sys.stderr)
|
|
|
|
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)
|
|
TOP_EDGES = (WindowEdge.Top, WindowEdge.TopLeft, WindowEdge.TopRight)
|
|
BOTTOM_EDGES = (WindowEdge.Bottom, WindowEdge.BottomLeft, WindowEdge.BottomRight)
|
|
LEFT_EDGES = (WindowEdge.Left, WindowEdge.TopLeft, WindowEdge.BottomLeft)
|
|
RIGHT_EDGES = (WindowEdge.Right, WindowEdge.TopRight, WindowEdge.BottomRight)
|
|
|
|
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())
|
|
|
|
# RESIZE HELPERS
|
|
|
|
def border_size(self):
|
|
'''Get the size of the border, regardless if present.'''
|
|
return QtCore.QSize(2 * self._border, 2 * self._border)
|
|
|
|
def minimized_content_size(self):
|
|
'''Get the minimum content size of the widget.'''
|
|
return self._titlebar_size
|
|
|
|
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
|
|
|
|
def minimum_size(self):
|
|
'''Get the minimum size for the widget.'''
|
|
|
|
size = self.minimized_size
|
|
if getattr(self, '_sizegrip', None) is not None and self._sizegrip.isVisible():
|
|
# Don't modify in place: percolates later.
|
|
size = size + self._sizegrip_size
|
|
|
|
if getattr(self, '_statusbar', None) is not None and self._statusbar.isVisible():
|
|
size = size + self._statusbar_size
|
|
|
|
return size
|
|
|
|
def get_larger_size(x, y):
|
|
'''Get the larger of the two sizes, for both the height and width.'''
|
|
return QtCore.QSize(max(x.width(), y.width()), max(x.height(), y.height()))
|
|
|
|
def set_minimum_size(self):
|
|
'''Sets the minimum size of the window and the titlebar, with clobbering.'''
|
|
|
|
self._old_minimum_size = self.minimumSize()
|
|
self._titlebar.set_minimum_size()
|
|
self._titlebar_size = self._titlebar.minimumSize()
|
|
self.setMinimumSize(self.minimum_size)
|
|
|
|
def set_larger_minimum_size(self):
|
|
'''Sets the minimum size of the window and the titlebar, without clobbering.'''
|
|
|
|
if self._old_minimum_size is not None:
|
|
self.setMinimumSize(self._old_minimum_size)
|
|
self._titlebar.set_minimum_size()
|
|
self._titlebar_size = self._titlebar.minimumSize()
|
|
size = get_larger_size(self.minimum_size, self.minimumSize())
|
|
self.setMinimumSize(size)
|
|
|
|
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.
|
|
# NOTICE: this fails on Wayland. Worse, using `QMainWindow::move` on
|
|
# Wayland causes the cursor position to be incorrect, causing issues
|
|
# with other events.
|
|
if IS_WAYLAND and self.window() == self:
|
|
return
|
|
|
|
self.move(position)
|
|
rect = self._titlebar._window_rect
|
|
if rect is not None:
|
|
rect.moveTo(position)
|
|
|
|
def set_geometry(self, rect):
|
|
'''Set the window geometry.'''
|
|
|
|
# See `move_to` for documentation.
|
|
self.resize(rect.size())
|
|
window_rect = self._titlebar._window_rect
|
|
if window_rect is not None:
|
|
window_rect.setSize(rect.size())
|
|
|
|
move_to(self, rect.topLeft())
|
|
|
|
def shade(self, size, grip_type):
|
|
'''Shade the window, hiding the main widget and size grip.'''
|
|
|
|
self._widget.hide()
|
|
if getattr(self, f'_{grip_type}') is not None:
|
|
getattr(self, f'_{grip_type}').hide()
|
|
self.set_minimum_size()
|
|
self.resize(size)
|
|
|
|
def unshade(self, rect, grip_type):
|
|
'''Unshade the window, showing the main widget and size grip.'''
|
|
|
|
self._widget.show()
|
|
if getattr(self, f'_{grip_type}') is not None:
|
|
getattr(self, f'_{grip_type}').show()
|
|
self.set_larger_minimum_size()
|
|
self.set_geometry(rect)
|
|
|
|
def start_drag(self, event, window_type):
|
|
'''Start the window drag state.'''
|
|
setattr(self, f'_{window_type}_drag', event.pos())
|
|
|
|
def handle_drag(self, event, window, window_type):
|
|
'''Handle the window drag event.'''
|
|
|
|
position = event.pos() - getattr(self, f'_{window_type}_drag')
|
|
window.move_to(window.mapToParent(position))
|
|
|
|
def end_drag(self, window_type):
|
|
'''End the window drag state.'''
|
|
setattr(self, f'_{window_type}_drag', None)
|
|
|
|
def start_move(self, widget, window_type):
|
|
'''Start the window move state.'''
|
|
|
|
setattr(self, f'_{window_type}_move', widget)
|
|
widget.menu_move_to(QtGui.QCursor.pos())
|
|
|
|
def handle_move(self, position, window_type):
|
|
'''Handle the window move event.'''
|
|
getattr(self, f'_{window_type}_move').menu_move_to(position)
|
|
|
|
def end_move(self, window_type):
|
|
'''End the window move state.'''
|
|
setattr(self, f'_{window_type}_move', None)
|
|
|
|
def start_resize(self, window, window_type):
|
|
'''Start the window resize state.'''
|
|
|
|
# NOTE: We can't use a rubber band with mouse tracking,
|
|
# since mouse events only occurs if the user is holding
|
|
# down the house. Simulating a mouse click isn't enough,
|
|
# even if it sends a mouse press without a release.
|
|
setattr(self, f'_{window_type}_resize', window)
|
|
self.window().setCursor(compat.SizeFDiagCursor)
|
|
self.menu_size_to(QtGui.QCursor.pos())
|
|
|
|
# Grab the mouse so we can intercept the click event,
|
|
# and track hover events outside the app. This doesn't
|
|
# work on Wayland or on macOS.
|
|
# https://doc.qt.io/qt-5/qwidget.html#grabMouse
|
|
if not IS_TRUE_WAYLAND and not sys.platform == 'darwin':
|
|
self.window().grabMouse()
|
|
|
|
def handle_resize(self, position):
|
|
'''Handle the window resize event.'''
|
|
self.menu_size_to(position)
|
|
|
|
def end_resize(self, window_type):
|
|
'''End the window resize state.'''
|
|
|
|
window = getattr(self, f'_{window_type}_resize')
|
|
if window is None:
|
|
return
|
|
|
|
setattr(self, f'_{window_type}_resize', None)
|
|
window.window().unsetCursor()
|
|
if not IS_TRUE_WAYLAND and not sys.platform == 'darwin':
|
|
self.window().releaseMouse()
|
|
|
|
def start_frame(self, frame, window_type):
|
|
'''Start the window frame resize state.'''
|
|
setattr(self, f'_{window_type}_frame', frame)
|
|
|
|
def handle_frame(self, window, event, window_type):
|
|
'''Handle the window frame resize event.'''
|
|
|
|
# Check if use size grips, return early.
|
|
frame = getattr(window, '_sizeframe', None)
|
|
if frame is None:
|
|
return
|
|
self.frame_event(event, frame)
|
|
|
|
# Store if the frame state is active.
|
|
if frame.is_active and not getattr(self, f'_{window_type}_frame'):
|
|
start_frame(self, frame, window_type)
|
|
elif not frame.is_active and getattr(self, f'_{window_type}_frame'):
|
|
end_frame(self, window_type)
|
|
|
|
def end_frame(self, window_type):
|
|
'''End the window frame resize state.'''
|
|
setattr(self, f'_{window_type}_frame', None)
|
|
|
|
# EVENT HANDLES
|
|
|
|
def window_resize_event(self, event):
|
|
'''Ensure titlebar text elides normally.'''
|
|
|
|
# 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(type(self), self).resizeEvent(event)
|
|
|
|
|
|
def window_show_event(self, event, grip_type):
|
|
'''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 getattr(self, f'_{grip_type}') is not None:
|
|
grip_size = getattr(self, f'_{grip_type}').sizeHint()
|
|
setattr(self, f'_{grip_type}_size', QtCore.QSize(0, grip_size.height()))
|
|
size = get_larger_size(self.minimum_size, self.minimumSize())
|
|
self.setMinimumSize(size)
|
|
|
|
super(type(self), self).showEvent(event)
|
|
|
|
def window_mouse_double_click_event(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(type(self), self).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 window_mouse_press_event(self, event, window, window_type):
|
|
'''Override a mouse click on the titlebar to allow a move.'''
|
|
|
|
widget = self._titlebar
|
|
if widget.underMouse():
|
|
# `self.window()._subwindow_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 widget._is_shaded
|
|
has_frame = getattr(window, f'_{window_type}_frame') is not None
|
|
if is_left and not is_minimized and not has_frame:
|
|
start_drag(self.window(), event, window_type)
|
|
elif event.button() == compat.RightButton:
|
|
position = shared.single_point_global_position(args, event)
|
|
shared.execute(args, widget._main_menu, position)
|
|
return super(type(self), self).mousePressEvent(event)
|
|
|
|
def window_mouse_move_event(self, event, window, window_type):
|
|
'''Reposition the window on the move event.'''
|
|
|
|
if getattr(window, f'_{window_type}_frame') is not None:
|
|
end_drag(window, window_type)
|
|
if getattr(window, f'_{window_type}_drag') is not None:
|
|
handle_drag(window, event, self, window_type)
|
|
return super(type(self), self).mouseMoveEvent(event)
|
|
|
|
def window_mouse_release_event(self, event, window, window_type):
|
|
'''End the drag event.'''
|
|
|
|
end_drag(window, window_type)
|
|
return super(type(self), self).mouseReleaseEvent(event)
|
|
|
|
# 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, window, parent=None, flags=None):
|
|
super().__init__(parent)
|
|
|
|
# Get and set some properties.
|
|
self.setProperty('isTitlebar', True)
|
|
self._window = window
|
|
self._window_type = 'window'
|
|
if isinstance(self._window, SubWindow):
|
|
self._window_type = 'subwindow'
|
|
self._state = compat.WindowNoState
|
|
self._window_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._window.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)
|
|
self._state1_column = col
|
|
col += 1
|
|
self._layout.addWidget(self._max, 0, col)
|
|
self._state2_column = col
|
|
col += 1
|
|
if self._has_shade:
|
|
self._layout.addWidget(self._shade, 0, col)
|
|
col += 1
|
|
self._layout.addWidget(self._close, 0, col)
|
|
self._close_column = 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._window.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 window has no state.'''
|
|
return self._state == compat.WindowNoState
|
|
|
|
def isMinimized(self):
|
|
'''Get if the titlebar and therefore window is minimized.'''
|
|
return self._state == compat.WindowMinimized
|
|
|
|
def isMaximized(self):
|
|
'''Get if the titlebar and therefore window 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.'''
|
|
start_move(self.window(), self, self._window_type)
|
|
|
|
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._window
|
|
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.'''
|
|
|
|
window = self.window()
|
|
start_resize(window, self._window, self._window_type)
|
|
|
|
def minimize(self):
|
|
'''Minimize the current window.'''
|
|
|
|
if self.isNormal():
|
|
self._window_rect = self._window.geometry()
|
|
self.set_minimized()
|
|
self.set_shaded()
|
|
|
|
# Toggle state
|
|
self._state = compat.WindowMinimized
|
|
self._is_shaded = False
|
|
self._window.minimize(self._window.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)
|
|
|
|
def maximize(self):
|
|
'''Maximize the current window.'''
|
|
|
|
if self.isNormal():
|
|
self._window_rect = self._window.geometry()
|
|
elif self.isMinimized() and not self._is_shaded:
|
|
self._window.unminimize()
|
|
size = self._window.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._window.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 window (set to no state).'''
|
|
|
|
if self.isMinimized() and not self._is_shaded:
|
|
self._window.unminimize()
|
|
self.set_restored()
|
|
self.set_unshaded()
|
|
|
|
# Toggle state
|
|
self._state = compat.WindowNoState
|
|
self._is_shaded = False
|
|
self._window.restore(self._window_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 window.'''
|
|
|
|
# Shaded windows are treated as if they have minimized state, and
|
|
# if the window is maximized, it sets the previous window rect
|
|
# to the maximized geometry.
|
|
self.set_shaded()
|
|
self.set_minimized()
|
|
|
|
# Toggle state
|
|
self._state = compat.WindowMinimized
|
|
self._is_shaded = True
|
|
self._window_rect = self._window.geometry()
|
|
width = self._window.width()
|
|
height = self._window.minimized_size.height()
|
|
self._window.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 window.'''
|
|
|
|
if self.isMinimized() and not self._is_shaded:
|
|
self._window.unminimize()
|
|
|
|
# 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._window.unshade(self._window_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.'''
|
|
|
|
# If we have a top-level widget, changing the window
|
|
# flags causes `setParent` to be called, causing the
|
|
# widget to hide and then re-appear. This causes major
|
|
# visual delay, so we just ignore the hide event, then
|
|
# set the flags, re-show the window, and unignore hides.
|
|
# Finally, this can change the geometry of the window,
|
|
# so we need to store the geometry and reset it.
|
|
if self._window.window() == self._window:
|
|
self._window._ignore_hide = True
|
|
rect = self.window().geometry()
|
|
|
|
flags = self._window.windowFlags()
|
|
if checked:
|
|
flags |= compat.WindowStaysOnTopHint
|
|
else:
|
|
flags &= ~compat.WindowStaysOnTopHint
|
|
self._window.setWindowFlags(flags)
|
|
|
|
if self._window.window() == self._window:
|
|
self._window._ignore_hide = False
|
|
self.window().show()
|
|
self.window().set_geometry(rect)
|
|
|
|
def help(self):
|
|
'''Enter what's this mode.'''
|
|
QtWidgets.QWhatsThis.enterWhatsThisMode()
|
|
|
|
# VIEW
|
|
|
|
def set_minimized(self):
|
|
'''Show the restore and maximize icons.'''
|
|
|
|
if self.isMinimized():
|
|
return
|
|
|
|
item1 = self._layout.itemAtPosition(0, self._state1_column)
|
|
item2 = self._layout.itemAtPosition(0, self._state2_column)
|
|
self._layout.removeItem(item1)
|
|
self._layout.removeItem(item2)
|
|
self._layout.addWidget(self._restore, 0, self._state1_column)
|
|
self._layout.addWidget(self._max, 0, self._state2_column)
|
|
self._min.hide()
|
|
self._restore.show()
|
|
self._max.show()
|
|
|
|
def set_maximized(self):
|
|
'''Show the minimize and restore icons.'''
|
|
|
|
if self.isMaximized():
|
|
return
|
|
|
|
item1 = self._layout.itemAtPosition(0, self._state1_column)
|
|
item2 = self._layout.itemAtPosition(0, self._state2_column)
|
|
self._layout.removeItem(item1)
|
|
self._layout.removeItem(item2)
|
|
self._layout.addWidget(self._min, 0, self._state1_column)
|
|
self._layout.addWidget(self._restore, 0, self._state2_column)
|
|
self._max.hide()
|
|
self._min.show()
|
|
self._restore.show()
|
|
|
|
def set_restored(self):
|
|
'''Show the minimize and maximize icons.'''
|
|
|
|
if self.isNormal():
|
|
return
|
|
|
|
item1 = self._layout.itemAtPosition(0, self._state1_column)
|
|
item2 = self._layout.itemAtPosition(0, self._state2_column)
|
|
self._layout.removeItem(item1)
|
|
self._layout.removeItem(item2)
|
|
self._layout.addWidget(self._min, 0, self._state1_column)
|
|
self._layout.addWidget(self._max, 0, self._state2_column)
|
|
self._restore.hide()
|
|
self._min.show()
|
|
self._max.show()
|
|
|
|
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, rect):
|
|
'''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 = rect.topLeft()
|
|
if self._window.window() != self._window:
|
|
point = self._window.parent().mapToGlobal(point)
|
|
|
|
return point
|
|
|
|
def frame_geometry(self):
|
|
'''Calculate the frame geometry of our window in global coordinates.'''
|
|
|
|
rect = self._window.frameGeometry()
|
|
return QtCore.QRect(self.top_left(rect), self._window.frameSize())
|
|
|
|
def geometry(self):
|
|
'''Calculate the geometry of our window in global coordinates.'''
|
|
|
|
rect = self._window.geometry()
|
|
return QtCore.QRect(self.top_left(rect), self._window.size())
|
|
|
|
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 resize(self, position, rect):
|
|
'''Resize our window to the adjusted dimensions.'''
|
|
|
|
# Get our new frame dimensions.
|
|
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)
|
|
|
|
# Ensure we don't drag the widgets if we go below min sizes.
|
|
if rect.width() < self._window.minimumWidth():
|
|
if self._press_edge in LEFT_EDGES:
|
|
rect.setLeft(rect.right() - self._window.minimumWidth())
|
|
elif self._press_edge in RIGHT_EDGES:
|
|
rect.setRight(rect.left() + self._window.minimumWidth())
|
|
if rect.height() < self._window.minimumHeight():
|
|
if self._press_edge in TOP_EDGES:
|
|
rect.setTop(rect.bottom() - self._window.minimumHeight())
|
|
elif self._press_edge in BOTTOM_EDGES:
|
|
rect.setBottom(rect.top() + self._window.minimumHeight())
|
|
|
|
# Calculate our rect for our widget.
|
|
size = rect.size()
|
|
point = rect.topLeft()
|
|
if self._window.window() != self._window:
|
|
point = self._window.parent().mapFromGlobal(point)
|
|
local_rect = QtCore.QRect(point, size)
|
|
|
|
# If we have a subwindow, need to limit to the MDI area rect.
|
|
if self._window.window() != self._window:
|
|
area_rect = self._window.mdiArea().contentsRect()
|
|
# Need to calculate our shifts here.
|
|
dx1 = max(local_rect.left(), area_rect.left()) - local_rect.left()
|
|
dy1 = max(local_rect.top(), area_rect.top()) - local_rect.top()
|
|
dx2 = min(local_rect.right(), area_rect.right()) - local_rect.right()
|
|
dy2 = min(local_rect.bottom(), area_rect.bottom()) - local_rect.bottom()
|
|
rect.adjust(dx1, dy1, dx2, dy2)
|
|
# NOTE: Do not remove this. I have tried everything.
|
|
# This does not work unless you keep it. There's a weird
|
|
# bug where the window (only for QMdiSubWindow) now has
|
|
# a bug where if you click on the title bar, it re-enters
|
|
# a resize mode, which is independent of this. Shifting
|
|
# the position by 1 pixel undoes this. Nothing else works,
|
|
# and I have tried:
|
|
# - Not due to custom drag/move/resize/frame states.
|
|
# - Not due to lingering pressed/press_edge/move_edge.
|
|
# - Not due to a lingering cursor.
|
|
# - Not due to change event.
|
|
# - Not due to resize/show event.
|
|
# - Not due to mouse press/double click/release/move event.
|
|
# - Not due to the event filter.
|
|
# - Not due to the QMainWindow-level custom title bar.
|
|
# - `setFixedSize` on the window on the mouse release
|
|
# and then undoing on the next resize event eats
|
|
# the mouse click, but still enters the same mode
|
|
# (just the window can't be resized).
|
|
# - Not due to the window-level widgets or margins.
|
|
# - `setFixedSize` on the title bar just fixes title bar size.
|
|
# - No previous versions work if we use the local_rect.
|
|
# - Unsetting the band and use the window directly does nothing.
|
|
# - Using `setGeometry(rect)` then `setGeometry(local_rect)`.
|
|
# - Unsetting the band geometry in `mouse_release` event.
|
|
# - Simulating mouse press+release in `mouse_release`.
|
|
# - Simulating mouse press+release in `end_frame`.
|
|
# - Ignoring the subsequent mousePressEvent on the title bar.
|
|
# - This causes the window to disappear entirely.
|
|
# - Hide+show inside `mouse_release` causes window to hide.
|
|
# - Hide+show inside `end_frame` causes window to hide.
|
|
# - Not due to minimum rect size checks.
|
|
# - Not due to MDI area limit checks.
|
|
# - Not related to custom restore/min/max/shade/unshade code.
|
|
# - Not due to custom hide/setVisible overrides.
|
|
#
|
|
# This is almost certain a bug in QMdiArea, but this is a
|
|
# workaround that produces almost is almost imperceptible,
|
|
# since the widget is being actively resized.
|
|
#
|
|
# I love mess.... but not this.
|
|
if dx1 == 0 and dy1 == 0 and dx2 == 0 and dy2 == 0:
|
|
dx1 += 1
|
|
dy1 += 1
|
|
dx2 += 1
|
|
dy2 += 1
|
|
local_rect.adjust(dx1, dy1, dx2, dy2)
|
|
|
|
self._window.set_geometry(local_rect)
|
|
self._band.setGeometry(rect)
|
|
|
|
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
|
|
|
|
self.resize(position, self._band.geometry())
|
|
|
|
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 handle drags, so only
|
|
# set this if we are pressing on the edge.
|
|
if self._press_edge != WindowEdge.NoEdge:
|
|
self._pressed = True
|
|
self._band.setGeometry(self.geometry())
|
|
|
|
def mouse_release(self, event):
|
|
'''Handle the mouseReleaseEvent of the window.'''
|
|
|
|
if event.button() == compat.LeftButton and self._pressed:
|
|
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):
|
|
'''Base subclass for a QMdiSubwindow.'''
|
|
|
|
def __init__(self, parent=None, flags=QtCore.Qt.WindowType(0)):
|
|
super().__init__(parent, flags=flags)
|
|
super().setWidget(QtWidgets.QWidget())
|
|
|
|
class DefaultSubWindow(SubWindow):
|
|
'''Default subwindow with a window frame.'''
|
|
|
|
def __init__(
|
|
self,
|
|
parent=None,
|
|
flags=QtCore.Qt.WindowType(0),
|
|
sizegrip=False,
|
|
):
|
|
super().__init__(parent, flags=flags)
|
|
|
|
class FramelessSubWindow(SubWindow):
|
|
'''Custom subwindow instance without a window frame.'''
|
|
|
|
def __init__(
|
|
self,
|
|
parent=None,
|
|
flags=QtCore.Qt.WindowType(0),
|
|
sizegrip=False,
|
|
):
|
|
super().__init__(parent, flags=flags | compat.FramelessWindowHint)
|
|
|
|
# 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()
|
|
self._old_minimum_size = None
|
|
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 border_size(self)
|
|
|
|
@property
|
|
def minimized_content_size(self):
|
|
'''Get the minimum content size of the widget.'''
|
|
return minimized_content_size(self)
|
|
|
|
@property
|
|
def minimized_size(self):
|
|
'''Get the minimum size of the widget, with the size grips hidden.'''
|
|
return minimized_size(self)
|
|
|
|
@property
|
|
def minimum_size(self):
|
|
'''Get the minimum size for the widget.'''
|
|
return minimum_size(self)
|
|
|
|
@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'''
|
|
move_to(self, position)
|
|
|
|
def set_geometry(self, rect):
|
|
'''Set the window geometry.'''
|
|
set_geometry(self, rect)
|
|
|
|
def set_minimum_size(self):
|
|
'''Sets the minimum size of the window and the titlebar, with clobbering.'''
|
|
set_minimum_size(self)
|
|
|
|
def set_larger_minimum_size(self):
|
|
'''Sets the minimum size of the window and the titlebar, without clobbering.'''
|
|
set_larger_minimum_size(self)
|
|
|
|
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)
|
|
self.mdiArea().minimize(self)
|
|
|
|
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_larger_minimum_size()
|
|
self.set_geometry(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_larger_minimum_size()
|
|
self.set_geometry(rect)
|
|
|
|
def shade(self, size):
|
|
'''Shade the window, hiding the main widget and size grip.'''
|
|
shade(self, size, 'sizegrip')
|
|
|
|
def unshade(self, rect):
|
|
'''Unshade the window, showing the main widget and size grip.'''
|
|
unshade(self, rect, 'sizegrip')
|
|
|
|
def unminimize(self):
|
|
'''Unminimize a minimized subwindow.'''
|
|
self.mdiArea().unminimize(self)
|
|
|
|
# QT EVENTS
|
|
|
|
def resizeEvent(self, event):
|
|
'''Handle widget resize events here.'''
|
|
window_resize_event(self, event)
|
|
|
|
def showEvent(self, event):
|
|
'''Set the minimum size policies once the widgets are shown.'''
|
|
window_show_event(self, event, 'sizegrip')
|
|
|
|
def mouseDoubleClickEvent(self, event):
|
|
'''Override the mouse double click, and don't call the press event.'''
|
|
window_mouse_double_click_event(self, event)
|
|
|
|
def mousePressEvent(self, event):
|
|
'''Override a mouse click on the titlebar to allow a move.'''
|
|
return window_mouse_press_event(self, event, self.window(), 'subwindow')
|
|
|
|
def mouseMoveEvent(self, event):
|
|
'''Reposition the window on the move event.'''
|
|
return window_mouse_move_event(self, event, self.window(), 'subwindow')
|
|
|
|
def mouseReleaseEvent(self, event):
|
|
'''End the drag event.'''
|
|
return window_mouse_release_event(self, event, self.window(), 'subwindow')
|
|
|
|
# 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()
|
|
|
|
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):
|
|
'''Base subclass for a QMainWindow.'''
|
|
|
|
def __init__(self, parent=None, flags=QtCore.Qt.WindowType(0)):
|
|
super().__init__(parent, flags)
|
|
|
|
# Tracking for move and resize events.
|
|
# Click and drag title bar move.
|
|
self._subwindow_drag = None
|
|
# Context menu move.
|
|
self._subwindow_move = None
|
|
# Context menu resize.
|
|
self._subwindow_resize = None
|
|
# SizeFrame resize.
|
|
self._subwindow_frame = None
|
|
|
|
def setup(self):
|
|
'''Setup the main UI.'''
|
|
|
|
subwindow_class = FramelessSubWindow
|
|
if args.default_window_frame:
|
|
subwindow_class = DefaultSubWindow
|
|
|
|
self.resize(1068, 824)
|
|
self.setWindowTitle('Custom SubWindow Style.')
|
|
|
|
flags = compat.SubWindow
|
|
self.area = MdiArea(self._widget)
|
|
self.window1 = subwindow_class(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
|
|
self.window2 = subwindow_class(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
|
|
self.window3 = subwindow_class(flags=flags, sizegrip=True)
|
|
self.window3.setWindowTitle('Medium length title')
|
|
self.area.addSubWindow(self.window3)
|
|
self._widget.layout().addWidget(self.area)
|
|
self.tab = SettingTabs(self.window3.widget())
|
|
self.window3.layout().addWidget(self.tab)
|
|
|
|
# PROPERTIES
|
|
|
|
@property
|
|
def maximum_size(self):
|
|
'''Get the maximum size for the window.'''
|
|
# Unused since we use the window flags anyway.
|
|
return self.maximumSize()
|
|
|
|
# ACTIONS
|
|
|
|
def menu_size_to(self, point):
|
|
'''
|
|
Size the window so that the position is in the center bottom
|
|
of the title bar. The position is given in global coordinates.
|
|
'''
|
|
|
|
window = getattr(self, '_window_resize', None)
|
|
if window is None:
|
|
window = self._subwindow_resize
|
|
rect = window.geometry()
|
|
# We add a trivial amount so we avoid a bug where we are
|
|
# exactly on the sizegrip, which keeps us in resize mode
|
|
# unless we do another button click, we weirdly doesn't
|
|
# work well when simulated.
|
|
point += QtCore.QPoint(2, 2)
|
|
|
|
# If we have a subwindow, need to limit to the MDI area rect.
|
|
if window.window() != window:
|
|
point = window.parent().mapFromGlobal(point)
|
|
area_rect = window.mdiArea().contentsRect()
|
|
point.setX(min(point.x(), area_rect.right()))
|
|
point.setY(min(point.y(), area_rect.bottom()))
|
|
|
|
# Need to ensure we didn't go past the top left.
|
|
# Don't want to shift to negative values.
|
|
top_left = rect.topLeft()
|
|
point.setX(max(top_left.x(), point.x()))
|
|
point.setY(max(top_left.y(), point.y()))
|
|
|
|
# We add a trivial amount to simplify growing the window on Wayland.
|
|
# Wayland cannot track outside of the application.
|
|
if IS_TRUE_WAYLAND and window.window() == window:
|
|
point += QtCore.QPoint(16, 16)
|
|
rect.setBottomRight(point)
|
|
window.set_geometry(rect)
|
|
|
|
# Ensure we trigger the elide resize timer.
|
|
titlebar = window._titlebar
|
|
titlebar._title._timer.start(REPAINT_TIMER)
|
|
|
|
def resolve_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.
|
|
# We deal with the window-level widgets first, then the subwindow-level
|
|
# widgets next. We use `getattr(obj, attr, None)` for the window-level
|
|
# widgets since they might not be present (if using Wayland).
|
|
|
|
has_state = False
|
|
if has_state or getattr(self, f'_window_frame', None) is not None:
|
|
end_resize(self, 'window')
|
|
has_state = True
|
|
if has_state or getattr(self, f'_window_resize', None) is not None:
|
|
end_move(self, 'window')
|
|
has_state = True
|
|
if has_state or getattr(self, f'_window_move', None) is not None:
|
|
end_drag(self, 'window')
|
|
has_state = True
|
|
if has_state or getattr(self, f'_window_drag', None) is not None:
|
|
end_frame(self, 'window')
|
|
has_state = True
|
|
if has_state or self._subwindow_frame is not None:
|
|
end_resize(self, 'subwindow')
|
|
has_state = True
|
|
if has_state or self._subwindow_resize is not None:
|
|
end_move(self, 'subwindow')
|
|
has_state = True
|
|
if has_state or self._subwindow_move is not None:
|
|
end_drag(self, 'subwindow')
|
|
has_state = True
|
|
|
|
def move_event(self, _, event, window_type):
|
|
'''Handle window move events.'''
|
|
|
|
if event.type() == compat.MouseMove:
|
|
position = shared.single_point_global_position(args, event)
|
|
handle_move(self, position, window_type)
|
|
elif event.type() == compat.MouseButtonPress:
|
|
end_move(self, window_type)
|
|
|
|
def resize_event(self, obj, event, window_type):
|
|
'''Handle window resize events.'''
|
|
|
|
# NOTE: If we're on Wayland, we cant' track hover events outside the
|
|
# main widget, and we can't guess intermittently since if the mouse
|
|
# doesn't move, we won't get an `Enter` or `HoverEnter` event, and
|
|
# `QCursor::pos` will always be the same. What this means is we
|
|
# can't guess where we left the, and resize until we're back
|
|
# in the bounds.
|
|
if event.type() in (compat.MouseMove, compat.HoverMove):
|
|
position = shared.single_point_global_position(args, event)
|
|
handle_resize(self, position)
|
|
elif event.type() == compat.MouseButtonPress:
|
|
end_resize(self, window_type)
|
|
|
|
def frame_event(self, event, frame):
|
|
'''Handle size adjustments using the window frame.'''
|
|
|
|
# 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)
|
|
|
|
# QT EVENTS
|
|
|
|
def eventFilter(self, obj, event):
|
|
'''Custom event filter to handle move and resize events.'''
|
|
|
|
self.resolve_state()
|
|
if getattr(self, '_window_move', None) is not None:
|
|
# Cannot occur while the size frame is active.
|
|
self.move_event(obj, event, 'window')
|
|
elif getattr(self, '_window_resize', None) is not None:
|
|
self.resize_event(obj, event, 'window')
|
|
elif isinstance(obj, Window) and not obj.isMinimized():
|
|
handle_frame(self, obj, event, 'window')
|
|
elif self._subwindow_move is not None:
|
|
# Cannot occur while the size frame is active.
|
|
self.move_event(obj, event, 'subwindow')
|
|
elif self._subwindow_resize is not None:
|
|
self.resize_event(obj, event, 'subwindow')
|
|
elif isinstance(obj, SubWindow) and not obj.isMinimized():
|
|
handle_frame(self, obj, event, 'subwindow')
|
|
|
|
return super().eventFilter(obj, event)
|
|
|
|
class DefaultWindow(Window):
|
|
'''Default main window with a window frame.'''
|
|
|
|
def __init__(self, parent=None, flags=QtCore.Qt.WindowType(0)):
|
|
if args.window_help:
|
|
flags |= compat.WindowContextHelpButtonHint
|
|
if args.window_shade:
|
|
flags |= compat.WindowShadeButtonHint
|
|
super().__init__(parent, flags)
|
|
|
|
self._central = QtWidgets.QFrame(self)
|
|
self._layout = QtWidgets.QVBoxLayout(self._central)
|
|
self.setCentralWidget(self._central)
|
|
self._widget = QtWidgets.QWidget(self._central)
|
|
self._widget.setLayout(QtWidgets.QVBoxLayout())
|
|
self._central.layout().addWidget(self._widget, 10)
|
|
|
|
if args.status_bar:
|
|
self._statusbar = QtWidgets.QStatusBar(self._central)
|
|
self.setStatusBar(self._statusbar)
|
|
|
|
self.setup()
|
|
|
|
class FramelessWindow(Window):
|
|
'''Main window with a custom event filter for all events.'''
|
|
|
|
def __init__(self, parent=None, flags=QtCore.Qt.WindowType(0)):
|
|
# On X11, the `WindowStaysOnTopHint` hint supposedly doesn't
|
|
# work unless you bypass the window manager, but this seems
|
|
# to no longer be true. There's major downsides to bypassing
|
|
# the window manager, so it's not worth it anyway.
|
|
flags |= compat.FramelessWindowHint
|
|
if args.window_help:
|
|
flags |= compat.WindowContextHelpButtonHint
|
|
if args.window_shade:
|
|
flags |= compat.WindowShadeButtonHint
|
|
super().__init__(parent, flags)
|
|
|
|
# Create our widgets. Sizeframe and sizegrip are mutually exclusive.
|
|
self._central = QtWidgets.QFrame(self)
|
|
self._layout = QtWidgets.QVBoxLayout(self._central)
|
|
self.setCentralWidget(self._central)
|
|
self._titlebar = TitleBar(self, self._central, flags)
|
|
self._widget = QtWidgets.QWidget(self._central)
|
|
self._widget.setLayout(QtWidgets.QVBoxLayout())
|
|
self._sizeframe = None
|
|
self._statusbar = None
|
|
self._border = args.border_width
|
|
self._titlebar_size = QtCore.QSize()
|
|
self._statusbar_size = QtCore.QSize()
|
|
self._old_minimum_size = None
|
|
if args.status_bar:
|
|
self._statusbar = QtWidgets.QStatusBar(self._central)
|
|
self.setStatusBar(self._statusbar)
|
|
else:
|
|
self._sizeframe = SizeFrame(self, border_width=5)
|
|
|
|
self._central.layout().setSpacing(0)
|
|
self._central.layout().addWidget(self._titlebar, 0, compat.AlignTop)
|
|
self._central.layout().addWidget(self._widget, 10)
|
|
|
|
# Tracking for move and resize events.
|
|
# Click and drag title bar move.
|
|
self._window_drag = None
|
|
# Context menu move.
|
|
self._window_move = None
|
|
# Context menu resize.
|
|
self._window_resize = None
|
|
# SizeFrame resize.
|
|
self._window_frame = None
|
|
|
|
# For toggling window flags, which calls `setParent`, hiding the window.
|
|
# Since an immediate show causes an unminimize/re-minimize, this
|
|
# causes a serious visual lag.
|
|
self._ignore_hide = False
|
|
|
|
# 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()
|
|
|
|
self.setup()
|
|
|
|
# HACKS
|
|
|
|
def hide(self):
|
|
'''Override the hide event to ignore it if desired.'''
|
|
|
|
if self._ignore_hide:
|
|
return
|
|
super().hide()
|
|
|
|
def setVisible(self, value):
|
|
'''Override the hide event to ignore it if desired.'''
|
|
|
|
if self._ignore_hide and not value:
|
|
return
|
|
super().setVisible(value)
|
|
|
|
# PROPERTIES
|
|
|
|
@property
|
|
def border_size(self):
|
|
'''Get the size of the border, regardless if present.'''
|
|
return border_size(self)
|
|
|
|
@property
|
|
def minimized_content_size(self):
|
|
'''Get the minimum content size of the widget.'''
|
|
return minimized_content_size(self)
|
|
|
|
@property
|
|
def minimized_size(self):
|
|
'''Get the minimum size of the widget, with the size grips hidden.'''
|
|
return minimized_size(self)
|
|
|
|
@property
|
|
def minimum_size(self):
|
|
'''Get the minimum size for the widget.'''
|
|
return minimum_size(self)
|
|
|
|
# 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)
|
|
|
|
# RESIZE
|
|
|
|
def move_to(self, position):
|
|
'''Move the window to the desired position'''
|
|
move_to(self, position)
|
|
|
|
def set_geometry(self, rect):
|
|
'''Set the window geometry.'''
|
|
set_geometry(self, rect)
|
|
|
|
def set_minimum_size(self):
|
|
'''Sets the minimum size of the window and the titlebar, with clobbering.'''
|
|
set_minimum_size(self)
|
|
|
|
def set_larger_minimum_size(self):
|
|
'''Sets the minimum size of the window and the titlebar, without clobbering.'''
|
|
set_larger_minimum_size(self)
|
|
|
|
def minimize(self, _):
|
|
'''Minimize the window, using the actual OS to handle that.'''
|
|
self.showMinimized()
|
|
|
|
def maximize(self, _):
|
|
'''Minimize the window, using the actual OS to handle that.'''
|
|
self.showMaximized()
|
|
|
|
def restore(self, _):
|
|
'''Restore the window, showing the main widget and size grip.'''
|
|
self.showNormal()
|
|
|
|
def showNormal(self):
|
|
super().showNormal()
|
|
|
|
def shade(self, size):
|
|
'''Shade the window, hiding the main widget and size grip.'''
|
|
shade(self, size, 'statusbar')
|
|
|
|
def unshade(self, rect):
|
|
'''Unshade the window, showing the main widget and size grip.'''
|
|
unshade(self, rect, 'statusbar')
|
|
|
|
def unminimize(self):
|
|
'''Unminimize a minimized window (unimplemented).'''
|
|
|
|
# QT EVENTS
|
|
|
|
def resizeEvent(self, event):
|
|
'''Handle widget resize events here.'''
|
|
window_resize_event(self, event)
|
|
|
|
def showEvent(self, event):
|
|
'''Set the minimum size policies once the widgets are shown.'''
|
|
window_show_event(self, event, 'statusbar')
|
|
|
|
def mouseDoubleClickEvent(self, event):
|
|
'''Override the mouse double click, and don't call the press event.'''
|
|
window_mouse_double_click_event(self, event)
|
|
|
|
def mousePressEvent(self, event):
|
|
'''Override a mouse click on the titlebar to allow a move.'''
|
|
return window_mouse_press_event(self, event, self, 'window')
|
|
|
|
def mouseMoveEvent(self, event):
|
|
'''Reposition the window on the move event.'''
|
|
return window_mouse_move_event(self, event, self, 'window')
|
|
|
|
def mouseReleaseEvent(self, event):
|
|
'''End the drag event.'''
|
|
return window_mouse_release_event(self, event, self, 'window')
|
|
|
|
def changeEvent(self, event):
|
|
'''Catch state changes from outside our custom titlebar.'''
|
|
|
|
super().changeEvent(event)
|
|
|
|
# If we're restoring a top-level widget, need to ensure the
|
|
# state is properly restored to the correct icons.
|
|
if event.type() not in (compat.ActivationChange, compat.WindowStateChange):
|
|
return
|
|
|
|
# We have 3 states, and we can have combinations of some of them:
|
|
# - NoState
|
|
# - Minimized
|
|
# - Maximized
|
|
# - Minimized + Maximized (treat as Minimized).
|
|
state = self.windowState()
|
|
if state & compat.WindowMinimized:
|
|
self._titlebar.minimize()
|
|
elif state & compat.WindowMaximized:
|
|
self._titlebar.maximize()
|
|
else:
|
|
self._titlebar.restore()
|
|
|
|
def main():
|
|
'Application entry point'
|
|
|
|
window_class = FramelessWindow
|
|
# Wayland does not allow windows to reposition themselves: therefore,
|
|
# we cannot use the custom titlebar at the application level.
|
|
if args.default_window_frame or USE_WAYLAND_FRAME:
|
|
window_class = DefaultWindow
|
|
app, window = shared.setup_app(args, unknown, compat, window_class=window_class)
|
|
app.installEventFilter(window)
|
|
|
|
shared.set_stylesheet(args, app, compat)
|
|
return shared.exec_app(args, app, window, compat)
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|