diff --git a/Orange/canvas/application/canvasmain.py b/Orange/canvas/application/canvasmain.py index 47c71e939c0..0d3837674a0 100644 --- a/Orange/canvas/application/canvasmain.py +++ b/Orange/canvas/application/canvasmain.py @@ -684,11 +684,14 @@ def setup_menu(self): # View menu self.view_menu = QMenu(self.tr("&View"), self) - self.toolbox_menu = QMenu(self.tr("Widget Toolbox Style"), - self.view_menu) - self.toolbox_menu_group = \ - QActionGroup(self, objectName="toolbox-menu-group") + # find and insert window group presets submenu + window_groups = self.scheme_widget.findChild( + QAction, "window-groups-action" + ) + if isinstance(window_groups, QAction): + self.view_menu.addAction(window_groups) + self.view_menu.addSeparator() self.view_menu.addAction(self.toggle_tool_dock_expand) self.view_menu.addAction(self.show_log_action) self.view_menu.addAction(self.show_report_action) diff --git a/Orange/canvas/document/commands.py b/Orange/canvas/document/commands.py index 929fb0e582f..113cb56f3a8 100644 --- a/Orange/canvas/document/commands.py +++ b/Orange/canvas/document/commands.py @@ -2,6 +2,7 @@ Undo/Redo Commands """ +from typing import Callable from AnyQt.QtWidgets import QUndoCommand @@ -203,3 +204,34 @@ def redo(self): def undo(self): setattr(self.obj, self.attrname, self.oldvalue) + + +class SimpleUndoCommand(QUndoCommand): + """ + Simple undo/redo command specified by callable function pair. + Parameters + ---------- + redo: Callable[[], None] + A function expressing a redo action. + undo : Callable[[], None] + A function expressing a undo action. + text : str + The command's text (see `QUndoCommand.setText`) + parent : Optional[QUndoCommand] + """ + + def __init__(self, redo, undo, text, parent=None): + # type: (Callable[[], None], Callable[[], None], ...) -> None + super().__init__(text, parent) + self._redo = redo + self._undo = undo + + def undo(self): + # type: () -> None + """Reimplemented.""" + self._undo() + + def redo(self): + # type: () -> None + """Reimplemented.""" + self._redo() diff --git a/Orange/canvas/document/schemeedit.py b/Orange/canvas/document/schemeedit.py index 1437a7f5fc3..7efdf568cf3 100644 --- a/Orange/canvas/document/schemeedit.py +++ b/Orange/canvas/document/schemeedit.py @@ -15,10 +15,13 @@ from operator import attrgetter from urllib.parse import urlencode +from typing import List + from AnyQt.QtWidgets import ( QWidget, QVBoxLayout, QInputDialog, QMenu, QAction, QActionGroup, QUndoStack, QUndoCommand, QGraphicsItem, QGraphicsObject, - QGraphicsTextItem + QGraphicsTextItem, QFormLayout, QComboBox, QDialog, QDialogButtonBox, + QMessageBox ) from AnyQt.QtGui import ( QKeySequence, QCursor, QFont, QPainter, QPixmap, QColor, QIcon, @@ -352,6 +355,39 @@ def color_icon(color): self.__selectAllAction, self.__duplicateSelectedAction] + #: Top 'Window Groups' action + self.__windowGroupsAction = QAction( + self.tr("Window Groups"), self, objectName="window-groups-action", + toolTip="Manage preset widget groups" + ) + #: Action group containing action for every window group + self.__windowGroupsActionGroup = QActionGroup( + self.__windowGroupsAction, objectName="window-groups-action-group", + ) + self.__windowGroupsActionGroup.triggered.connect( + self.__activateWindowGroup + ) + self.__saveWindowGroupAction = QAction( + self.tr("Save Window Group..."), self, + toolTip="Create and save a new window group." + ) + self.__saveWindowGroupAction.triggered.connect(self.__saveWindowGroup) + self.__clearWindowGroupsAction = QAction( + self.tr("Delete All Groups"), self, + toolTip="Delete all saved widget presets" + ) + self.__clearWindowGroupsAction.triggered.connect( + self.__clearWindowGroups + ) + + groups_menu = QMenu(self) + sep = groups_menu.addSeparator() + sep.setObjectName("groups-separator") + groups_menu.addAction(self.__saveWindowGroupAction) + groups_menu.addSeparator() + groups_menu.addAction(self.__clearWindowGroupsAction) + self.__windowGroupsAction.setMenu(groups_menu) + def __setupUi(self): layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) @@ -635,6 +671,28 @@ def setScheme(self, scheme): if nodes: self.ensureVisible(nodes[0]) + group = self.__windowGroupsActionGroup + menu = self.__windowGroupsAction.menu() + actions = group.actions() + for a in actions: + group.removeAction(a) + menu.removeAction(a) + a.deleteLater() + + if scheme: + presets = scheme.property("_presets") or [] + sep = menu.findChild(QAction, "groups-separator") + assert isinstance(sep, QAction) + for name, state in presets: + a = QAction(name, menu) + a.setShortcut( + QKeySequence("Meta+P, Ctrl+{}" + .format(len(group.actions()) + 1)) + ) + a.setData(state) + group.addAction(a) + menu.insertAction(sep, a) + def ensureVisible(self, node): """ Scroll the contents of the viewport so that `node` is visible. @@ -1634,6 +1692,188 @@ def __signalManagerStateChanged(self, state): role = QPalette.Window self.__view.viewport().setBackgroundRole(role) + def __saveWindowGroup(self): + # Run a 'Save Window Group' dialog + workflow = self.__scheme # type: widgetsscheme.WidgetsScheme + state = [] + for node in workflow.nodes: # type: SchemeNode + w = workflow.widget_for_node(node) + if w.isVisible(): + data = workflow.save_widget_geometry_for_node(node) + state.append((node, data)) + + presets = workflow.property("_presets") or [] + items = [name for name, _ in presets] + + dlg = SaveWindowGroup( + self, windowTitle="Save Group as...") + dlg.setWindowModality(Qt.ApplicationModal) + dlg.setItems(items) + + menu = self.__windowGroupsAction.menu() # type: QMenu + group = self.__windowGroupsActionGroup + + def store_group(): + text = dlg.selectedText() + actions = group.actions() # type: List[QAction] + try: + idx = items.index(text) + except ValueError: + idx = -1 + newpresets = list(presets) + if idx == -1: + # new group slot + newpresets.append((text, state)) + action = QAction(text, menu) + action.setShortcut( + QKeySequence("Meta+P, Ctrl+{}".format(len(newpresets))) + ) + oldstate = None + else: + newpresets[idx] = (text, state) + action = actions[idx] + # store old state for undo + _, oldstate = presets[idx] + + sep = menu.findChild(QAction, "groups-separator") + assert isinstance(sep, QAction) and sep.isSeparator() + + def redo(): + action.setData(state) + workflow.setProperty("_presets", newpresets) + if idx == -1: + group.addAction(action) + menu.insertAction(sep, action) + + def undo(): + action.setData(oldstate) + workflow.setProperty("_presets", presets) + if idx == -1: + group.removeAction(action) + menu.removeAction(action) + if idx == -1: + text = "Store Window Group" + else: + text = "Update Window Group" + self.__undoStack.push( + commands.SimpleUndoCommand(redo, undo, text) + ) + dlg.accepted.connect(store_group) + dlg.show() + dlg.raise_() + + def __activateWindowGroup(self, action): + # type: (QAction) -> None + state = action.data() + workflow = self.__scheme + if not isinstance(workflow, widgetsscheme.WidgetsScheme): + return + + state = {node: geom for node, geom in state} + for node in workflow.nodes: + w = workflow.widget_for_node(node) # type: QWidget + w.setVisible(node in state) + if node in state: + workflow.restore_widget_geometry_for_node(node, state[node]) + w.raise_() + + def __clearWindowGroups(self): + workflow = self.__scheme + presets = workflow.property("_presets") or [] + menu = self.__windowGroupsAction.menu() # type: QMenu + group = self.__windowGroupsActionGroup + actions = group.actions() + + def redo(): + workflow.setProperty("_presets", []) + for action in reversed(actions): + group.removeAction(action) + menu.removeAction(action) + + def undo(): + workflow.setProperty("_presets", presets) + sep = menu.findChild(QAction, "groups-separator") + for action in actions: + group.addAction(action) + menu.insertAction(sep, action) + + self.__undoStack.push( + commands.SimpleUndoCommand(redo, undo, "Delete All Window Groups") + ) + + +class SaveWindowGroup(QDialog): + """ + A dialog for saving window groups. + + The user can select an existing group to overwrite or enter a new group + name. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + layout = QVBoxLayout() + form = QFormLayout( + fieldGrowthPolicy=QFormLayout.AllNonFixedFieldsGrow) + layout.addLayout(form) + self._combobox = cb = QComboBox( + editable=True, minimumContentsLength=16, + sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLength, + insertPolicy=QComboBox.NoInsert, + ) + # default text if no items are present + cb.setEditText(self.tr("Window Group 1")) + cb.lineEdit().selectAll() + form.addRow(self.tr("Save As:"), cb) + bb = QDialogButtonBox( + standardButtons=QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + bb.accepted.connect(self.__accept_check) + bb.rejected.connect(self.reject) + layout.addWidget(bb) + layout.setSizeConstraint(QVBoxLayout.SetFixedSize) + self.setLayout(layout) + self.setWhatsThis( + "Save the current open widgets' window arrangement to the " + "workflow view presets." + ) + cb.setFocus(Qt.NoFocusReason) + + def __accept_check(self): + cb = self._combobox + text = cb.currentText() + if cb.findText(text) == -1: + self.accept() + return + # Ask for overwrite confirmation + mb = QMessageBox( + self, windowTitle=self.tr("Confirm Overwrite"), + icon=QMessageBox.Question, + standardButtons=QMessageBox.Yes | QMessageBox.Cancel, + text=self.tr("The window group '{}' already exists. Do you want " + + "to replace it?").format(text), + ) + mb.setDefaultButton(QMessageBox.Yes) + mb.setEscapeButton(QMessageBox.Cancel) + mb.setWindowModality(Qt.WindowModal) + button = mb.button(QMessageBox.Yes) + button.setText(self.tr("Replace")) + mb.finished.connect( + lambda status: status == QMessageBox.Yes and self.accept() + ) + mb.show() + + def setItems(self, items): + # type: (List[str]) -> None + """Set a list of existing items/names to present to the user""" + self._combobox.clear() + self._combobox.addItems(items) + if items: + self._combobox.setCurrentIndex(len(items) - 1) + + def selectedText(self): + # type: () -> str + """Return the current entered text.""" + return self._combobox.currentText() + def geometry_from_annotation_item(item): if isinstance(item, items.ArrowAnnotation): diff --git a/Orange/canvas/scheme/readwrite.py b/Orange/canvas/scheme/readwrite.py index fd1cd7e4625..7dd96a24a01 100644 --- a/Orange/canvas/scheme/readwrite.py +++ b/Orange/canvas/scheme/readwrite.py @@ -2,13 +2,14 @@ Scheme save/load routines. """ -import base64 import sys import warnings +import base64 +import binascii from xml.etree.ElementTree import TreeBuilder, Element, ElementTree, parse -from collections import defaultdict, namedtuple +from collections import defaultdict from itertools import chain, count import pickle as pickle @@ -20,6 +21,8 @@ import logging +from typing import NamedTuple, Dict, Tuple, List, Union, Any + from . import SchemeNode, SchemeLink from .annotations import SchemeTextAnnotation, SchemeArrowAnnotation from .errors import IncompatibleChannelTypeError @@ -316,7 +319,7 @@ def parse_scheme_v_2_0(etree, scheme, error_handler, widget_registry=None, def parse_scheme_v_1_0(etree, scheme, error_handler, widget_registry=None, allow_pickle_data=False): """ - ElementTree Instance of an old .ows scheme format. + ElementTree Instance of an old .ows scheme format (Orange < 2.7). """ if widget_registry is None: widget_registry = global_registry() @@ -397,38 +400,82 @@ def parse_scheme_v_1_0(etree, scheme, error_handler, widget_registry=None, # Intermediate scheme representation -_scheme = namedtuple( - "_scheme", - ["title", "version", "description", "nodes", "links", "annotations"]) - -_node = namedtuple( - "_node", - ["id", "title", "name", "position", "project_name", "qualified_name", - "version", "data"]) - -_data = namedtuple( - "_data", - ["format", "data"]) - -_link = namedtuple( - "_link", - ["id", "source_node_id", "sink_node_id", "source_channel", "sink_channel", - "enabled"]) - -_annotation = namedtuple( - "_annotation", - ["id", "type", "params"]) - -_text_params = namedtuple( - "_text_params", - ["geometry", "text", "font", "content_type"]) - -_arrow_params = namedtuple( - "_arrow_params", - ["geometry", "color"]) +_scheme = NamedTuple( + "_scheme", [ + ("title", str), + ("version", str), + ("description", str), + ("nodes", 'List[_node]'), + ("links", 'List[_link]'), + ("annotations", 'List[_annotation]'), + ("session_state", '_session_data') + ] +) + +_node = NamedTuple( + "_node", [ + ("id", str), + ("title", str), + ("name", str), + ("position", 'Tuple[float, float]'), + ("project_name", str), + ("qualified_name", str), + ("version", str), + ("data", '_data') + ] +) + +_data = NamedTuple( + "_data", [ + ("format", str), + ("data", bytes) + ] +) + +_link = NamedTuple( + "_link", [ + ("id", str), + ("source_node_id", str), + ("sink_node_id", str), + ("source_channel", str), + ("sink_channel", str), + ("enabled", bool), + ] +) + +_annotation = NamedTuple( + "_annotation", [ + ("id", str), + ("type", str), + ("params", Union['_text_params', '_arrow_params']), + ] +) + +_text_params = NamedTuple( + "_text_params", [ + ("geometry", str), + ("text", str), + ("font", Dict[str, Any]), + ("content_type", str), + ] +) + +_arrow_params = NamedTuple( + "_arrow_params", [ + ("geometry", str), + ("color", str), + ]) + + +_session_data = NamedTuple( + "_session_data", [ + ("groups", List[Tuple[str, List[Tuple[str, bytes]]]]) + ] +) def parse_ows_etree_v_2_0(tree): + # type: (ElementTree) -> _scheme scheme = tree.getroot() nodes, links, annotations = [], [], [] @@ -499,15 +546,36 @@ def parse_ows_etree_v_2_0(tree): type="arrow", params=_arrow_params((start, end), color) ) + else: + log.warn("Unknown annotation '%s'. Skipping.", annot.tag) + continue annotations.append(annotation) + window_presets = [] + for window_group in tree.findall("session_state/window_groups/group"): + name = window_group.get("name") # type: str + state = [] + for state_ in window_group.findall("window_state"): + node_id = state_.get("node_id") # type: str + try: + data = base64.decodebytes(state_.text.encode("ascii")) + except binascii.Error: + data = b'' + except UnicodeDecodeError: + data = b'' + state.append((node_id, data)) + window_presets.append((name, state)) + + session_state = _session_data(window_presets) + return _scheme( version=scheme.get("version"), title=scheme.get("title", ""), description=scheme.get("description"), nodes=nodes, links=links, - annotations=annotations + annotations=annotations, + session_state=session_state, ) @@ -568,6 +636,7 @@ def parse_ows_etree_v_1_0(tree): def parse_ows_stream(stream): + # type: (...) -> _scheme doc = parse(stream) scheme_el = doc.getroot() version = scheme_el.get("version", None) @@ -659,7 +728,7 @@ def resolve_replaced(scheme_desc, registry): def scheme_load(scheme, stream, registry=None, error_handler=None): - desc = parse_ows_stream(stream) + desc = parse_ows_stream(stream) # type: _scheme if registry is None: registry = global_registry() @@ -735,6 +804,7 @@ def error_handler(exc): else: log.warning("Ignoring unknown annotation type: %r", annot_d.type) + continue annotations.append(annot) for node in nodes: @@ -746,17 +816,17 @@ def error_handler(exc): for annot in annotations: scheme.add_annotation(annot) + if desc.session_state.groups: + # resolve node_id -> node + groups = [] + for name, state in desc.session_state.groups: + state = [(nodes_by_id[node_id], data) + for node_id, data in state if node_id in nodes_by_id] + groups.append((name, state)) + scheme.setProperty("_presets", groups) return scheme -def inf_range(start=0, step=1): - """Return an infinite range iterator. - """ - while True: - yield start - start += step - - def scheme_to_etree(scheme, data_format="literal", pickle_fallback=False): """ Return an `xml.etree.ElementTree` representation of the `scheme. @@ -766,10 +836,10 @@ def scheme_to_etree(scheme, data_format="literal", pickle_fallback=False): "title": scheme.title or "", "description": scheme.description or ""}) - ## Nodes - node_ids = defaultdict(inf_range().__next__) + # Nodes + node_ids = defaultdict(count().__next__) builder.start("nodes", {}) - for node in scheme.nodes: + for node in scheme.nodes: # type: SchemeNode desc = node.description attrs = {"id": str(node_ids[node]), "name": desc.name, @@ -789,10 +859,10 @@ def scheme_to_etree(scheme, data_format="literal", pickle_fallback=False): builder.end("nodes") - ## Links - link_ids = defaultdict(inf_range().__next__) + # Links + link_ids = defaultdict(count().__next__) builder.start("links", {}) - for link in scheme.links: + for link in scheme.links: # type: SchemeLink source = link.source_node sink = link.sink_node source_id = node_ids[source] @@ -809,8 +879,8 @@ def scheme_to_etree(scheme, data_format="literal", pickle_fallback=False): builder.end("links") - ## Annotations - annotation_ids = defaultdict(inf_range().__next__) + # Annotations + annotation_ids = defaultdict(count().__next__) builder.start("annotations", {}) for annotation in scheme.annotations: annot_id = annotation_ids[annotation] @@ -867,6 +937,20 @@ def scheme_to_etree(scheme, data_format="literal", pickle_fallback=False): builder.end("properties") builder.end("node_properties") + builder.start("session_state", {}) + builder.start("window_groups", {}) + + for name, state in scheme.property("_presets") or []: + builder.start("group", {"name": name}) + for node, data in state: + if node not in node_ids: + continue + builder.start("window_state", {"node_id": str(node_ids[node])}) + builder.data(base64.encodebytes(data).decode("ascii")) + builder.end("window_state") + builder.end("group") + builder.end("window_group") + builder.end("session_state") builder.end("scheme") root = builder.close() tree = ElementTree(root) diff --git a/Orange/canvas/scheme/widgetsscheme.py b/Orange/canvas/scheme/widgetsscheme.py index 40791a4885d..c3b50a792ac 100644 --- a/Orange/canvas/scheme/widgetsscheme.py +++ b/Orange/canvas/scheme/widgetsscheme.py @@ -28,7 +28,10 @@ from AnyQt.QtWidgets import QWidget, QShortcut, QLabel, QSizePolicy, QAction from AnyQt.QtGui import QKeySequence, QWhatsThisClickedEvent -from AnyQt.QtCore import Qt, QObject, QCoreApplication, QTimer, QEvent +from AnyQt.QtCore import ( + Qt, QObject, QCoreApplication, QTimer, QEvent, QByteArray +) + from AnyQt.QtCore import pyqtSignal as Signal from .signalmanager import SignalManager, compress_signals, can_enable_dynamic @@ -85,6 +88,26 @@ def node_for_widget(self, widget): """ return self.widget_manager.node_for_widget(widget) + def save_widget_geometry_for_node(self, node): + # type: (SchemeNode) -> bytes + """ + Save and return the current geometry and state for node + + Parameters + ---------- + node : Scheme + """ + w = self.widget_for_node(node) # type: OWWidget + return bytes(w.saveGeometryAndLayoutState()) + + def restore_widget_geometry_for_node(self, node, state): + # type: (SchemeNode, bytes) -> bool + w = self.widget_for_node(node) + if w is not None: + return w.restoreGeometryAndLayoutState(QByteArray(state)) + else: + return False + def sync_node_properties(self): """ Sync the widget settings/properties with the SchemeNode.properties. diff --git a/Orange/widgets/tests/test_widget.py b/Orange/widgets/tests/test_widget.py index f06b347c99e..8eb46947090 100644 --- a/Orange/widgets/tests/test_widget.py +++ b/Orange/widgets/tests/test_widget.py @@ -2,7 +2,7 @@ # pylint: disable=missing-docstring from unittest.mock import patch, MagicMock - +from AnyQt.QtCore import QRect, QByteArray from AnyQt.QtGui import QShowEvent from AnyQt.QtWidgets import QAction @@ -187,3 +187,26 @@ def test_old_style_messages(self): w.Error.clear() self.assertEqual(len(messages), 0) + + def test_store_restore_layout_geom(self): + class Widget(OWWidget): + name = "Who" + want_control_area = True + + w = Widget() + splitter = w._OWWidget__splitter # type: OWWidget._Splitter + splitter.setControlAreaVisible(False) + w.setGeometry(QRect(51, 52, 53, 54)) + state = w.saveGeometryAndLayoutState() + w1 = Widget() + self.assertTrue(w1.restoreGeometryAndLayoutState(state)) + self.assertEqual(w1.geometry(), QRect(51, 52, 53, 54)) + self.assertFalse(w1.controlAreaVisible) + + Widget.want_control_area = False + w2 = Widget() + self.assertTrue(w2.restoreGeometryAndLayoutState(state)) + self.assertEqual(w1.geometry(), QRect(51, 52, 53, 54)) + + self.assertFalse((w2.restoreGeometryAndLayoutState(QByteArray()))) + self.assertFalse(w2.restoreGeometryAndLayoutState(QByteArray(b'ab'))) diff --git a/Orange/widgets/widget.py b/Orange/widgets/widget.py index 55aac79136d..f8bef6a2343 100644 --- a/Orange/widgets/widget.py +++ b/Orange/widgets/widget.py @@ -12,7 +12,7 @@ QProgressBar, QAction ) from AnyQt.QtCore import ( - Qt, QByteArray, QSettings, QUrl, pyqtSignal as Signal + Qt, QByteArray, QDataStream, QBuffer, QSettings, QUrl, pyqtSignal as Signal ) from AnyQt.QtGui import QIcon, QKeySequence, QDesktopServices @@ -479,8 +479,8 @@ def copy_to_clipboard(self): def storeControlAreaVisibility(self, visible): self.controlAreaVisible = visible - def __restoreWidgetGeometry(self): - + def __restoreWidgetGeometry(self, geometry): + # type: (bytes) -> bool def _fullscreen_to_maximized(geometry): """Don't restore windows into full screen mode because it loses decorations and can't be de-fullscreened at least on some platforms. @@ -493,31 +493,29 @@ def _fullscreen_to_maximized(geometry): return w.saveGeometry() restored = False - if self.save_position: - geometry = self.savedWidgetGeometry - if geometry is not None: - geometry = _fullscreen_to_maximized(geometry) - restored = self.restoreGeometry(geometry) - - if restored and not self.windowState() & \ - (Qt.WindowMaximized | Qt.WindowFullScreen): - space = QApplication.desktop().availableGeometry(self) - frame, geometry = self.frameGeometry(), self.geometry() - - #Fix the widget size to fit inside the available space - width = space.width() - (frame.width() - geometry.width()) - width = min(width, geometry.width()) - height = space.height() - (frame.height() - geometry.height()) - height = min(height, geometry.height()) - self.resize(width, height) - - # Move the widget to the center of available space if it is - # currently outside it - if not space.contains(self.frameGeometry()): - x = max(0, space.width() / 2 - width / 2) - y = max(0, space.height() / 2 - height / 2) - - self.move(x, y) + if geometry: + geometry = _fullscreen_to_maximized(geometry) + restored = self.restoreGeometry(geometry) + + if restored and not self.windowState() & \ + (Qt.WindowMaximized | Qt.WindowFullScreen): + space = QApplication.desktop().availableGeometry(self) + frame, geometry = self.frameGeometry(), self.geometry() + + # Fix the widget size to fit inside the available space + width = space.width() - (frame.width() - geometry.width()) + width = min(width, geometry.width()) + height = space.height() - (frame.height() - geometry.height()) + height = min(height, geometry.height()) + self.resize(width, height) + + # Move the widget to the center of available space if it is + # currently outside it + if not space.contains(self.frameGeometry()): + x = max(0, space.width() / 2 - width / 2) + y = max(0, space.height() / 2 - height / 2) + + self.move(x, y) # Mark as explicitly moved/resized if not already. QDialog would # otherwise adjust position/size on subsequent hide/show @@ -580,7 +578,8 @@ def showEvent(self, event): # Restore saved geometry on (first) show if self.__splitter is not None: self.__splitter.setControlAreaVisible(self.controlAreaVisible) - self.__restoreWidgetGeometry() + if self.savedWidgetGeometry is not None: + self.__restoreWidgetGeometry(bytes(self.savedWidgetGeometry)) self.__was_restored = True self.__quicktipOnce() @@ -776,6 +775,63 @@ def workflowEnvChanged(self, key, value, oldvalue): """ pass + def saveGeometryAndLayoutState(self): + # type: () -> QByteArray + """ + Save the current geometry and layout state of this widget and + child windows (if applicable). + + Returns + ------- + state : QByteArray + Saved state. + """ + version = 0x1 + have_spliter = 0 + splitter_state = 0 + if self.__splitter is not None: + have_spliter = 1 + splitter_state = 1 if self.controlAreaVisible else 0 + data = QByteArray() + stream = QDataStream(data, QBuffer.WriteOnly) + stream.writeUInt32(version) + stream.writeUInt16((have_spliter << 1) | splitter_state) + stream << self.saveGeometry() + return data + + def restoreGeometryAndLayoutState(self, state): + # type: (QByteArray) -> bool + """ + Restore the geometry and layout of this widget to a state previously + saved with :func:`saveGeometryAndLayoutState`. + + Parameters + ---------- + state : QByteArray + Saved state. + + Returns + ------- + success : bool + `True` if the state was successfully restored, `False` otherwise. + """ + version = 0x1 + stream = QDataStream(state, QBuffer.ReadOnly) + version_ = stream.readUInt32() + if stream.status() != QDataStream.Ok or version_ != version: + return False + splitter_state = stream.readUInt16() + has_spliter = splitter_state & 0x2 + splitter_state = splitter_state & 0x1 + if has_spliter and self.__splitter is not None: + self.__splitter.setControlAreaVisible(bool(splitter_state)) + geometry = QByteArray() + stream >> geometry + if stream.status() == QDataStream.Ok: + return self.__restoreWidgetGeometry(bytes(geometry)) + else: + return False # pragma: no cover + def __showMessage(self, message): if self.__msgwidget is not None: self.__msgwidget.hide()