From e4a577b606534829eb48d4c2b4b8d92d9eb17cbf Mon Sep 17 00:00:00 2001 From: mlauer154 Date: Thu, 31 Aug 2023 14:49:41 -0500 Subject: [PATCH] Exit handling, more decimals, context menu style inheritance --- pymead/gui/airfoil_pos_parameter.py | 9 ++ pymead/gui/autocomplete.py | 5 + pymead/gui/custom_context_menu_event.py | 54 +++++++++ pymead/gui/gui.py | 31 ++++- pymead/gui/input_dialog.py | 20 +++- pymead/gui/parameter_tree.py | 144 ++++++++++++++++++------ pymead/gui/selectable_header.py | 9 ++ 7 files changed, 228 insertions(+), 44 deletions(-) create mode 100644 pymead/gui/custom_context_menu_event.py diff --git a/pymead/gui/airfoil_pos_parameter.py b/pymead/gui/airfoil_pos_parameter.py index 78f35b2c..3314deb4 100644 --- a/pymead/gui/airfoil_pos_parameter.py +++ b/pymead/gui/airfoil_pos_parameter.py @@ -2,8 +2,14 @@ from pyqtgraph import SpinBox from PyQt5.QtWidgets import QWidget, QHBoxLayout, QDoubleSpinBox from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject +from PyQt5 import QtCore +from PyQt5.QtWidgets import QMenu + +translate = QtCore.QCoreApplication.translate import numpy as np +from pymead.gui.custom_context_menu_event import custom_context_menu_event + class SignalContainer(QObject): """Need to create a separate class to contain the composite signal because the WidgetParameterItem cannot be cast @@ -57,3 +63,6 @@ def either_changed(): widget.value = value widget.setValue = setValue return widget + + def contextMenuEvent(self, ev): + custom_context_menu_event(ev, self) diff --git a/pymead/gui/autocomplete.py b/pymead/gui/autocomplete.py index 6829ab61..2d77afdd 100644 --- a/pymead/gui/autocomplete.py +++ b/pymead/gui/autocomplete.py @@ -2,6 +2,8 @@ from PyQt5.QtCore import Qt from pyqtgraph.parametertree.parameterTypes.basetypes import WidgetParameterItem +from pymead.gui.custom_context_menu_event import custom_context_menu_event + class AutoStrParameterItem(WidgetParameterItem): """Parameter type which displays a QLineEdit with an auto-completion mechanism built in""" @@ -23,6 +25,9 @@ def makeWidget(self): self.widget = w return w + def contextMenuEvent(self, ev): + custom_context_menu_event(ev, self) + class Completer(QCompleter): """ diff --git a/pymead/gui/custom_context_menu_event.py b/pymead/gui/custom_context_menu_event.py new file mode 100644 index 00000000..1c40977b --- /dev/null +++ b/pymead/gui/custom_context_menu_event.py @@ -0,0 +1,54 @@ +from PyQt5.QtCore import QEvent, QCoreApplication +from PyQt5.QtWidgets import QMenu +from pyqtgraph.parametertree.ParameterItem import ParameterItem + +translate = QCoreApplication.translate + + +def custom_context_menu_event(ev: QEvent, param_item: ParameterItem): + """ + Re-implemented to provide style inherited from GUI main window + """ + opts = param_item.param.opts + + if not opts.get('removable', False) and not opts.get('renamable', False) \ + and "context" not in opts: + return + + ## Generate context menu for renaming/removing parameter + + # Loop to identify the GUI object + max_iter = 12 + iter_count = 0 + current_parent = param_item.param + gui = None + + while True: + if iter_count > max_iter: + break + current_parent = current_parent.parent() + if hasattr(current_parent, "status_bar"): + gui = current_parent.status_bar.parent() + break + + ss = gui.themes[gui.current_theme] + param_item.contextMenu = QMenu(parent=gui) # Put in global name space to prevent garbage collection + param_item.contextMenu.setStyleSheet(f"QMenu::item:selected {{ color: {ss['menu-main-color']}; background-color: {ss['menu-item-selected-color']} }} ") + param_item.contextMenu.addSeparator() + if opts.get('renamable', False): + param_item.contextMenu.addAction(translate("ParameterItem", 'Rename')).triggered.connect(param_item.editName) + if opts.get('removable', False): + param_item.contextMenu.addAction(translate("ParameterItem", "Remove")).triggered.connect(param_item.requestRemove) + + # context menu + context = opts.get('context', None) + if isinstance(context, list): + for name in context: + param_item.contextMenu.addAction(name).triggered.connect( + param_item.contextMenuTriggered(name)) + elif isinstance(context, dict): + for name, title in context.items(): + param_item.contextMenu.addAction(title).triggered.connect( + param_item.contextMenuTriggered(name)) + + param_item.contextMenu.popup(ev.globalPos()) diff --git a/pymead/gui/gui.py b/pymead/gui/gui.py index adbdbeaa..271cf455 100644 --- a/pymead/gui/gui.py +++ b/pymead/gui/gui.py @@ -33,7 +33,8 @@ from pymead import RESOURCE_DIR from pymead.gui.input_dialog import SingleAirfoilViscousDialog, LoadDialog, SaveAsDialog, OptimizationSetupDialog, \ MultiAirfoilDialog, ColorInputDialog, ExportCoordinatesDialog, ExportControlPointsDialog, AirfoilPlotDialog, \ - AirfoilMatchingDialog, MSESFieldPlotDialog, ExportIGESDialog, XFOILDialog, NewMEADialog, EditBoundsDialog + AirfoilMatchingDialog, MSESFieldPlotDialog, ExportIGESDialog, XFOILDialog, NewMEADialog, EditBoundsDialog, \ + ExitDialog from pymead.gui.pymeadPColorMeshItem import PymeadPColorMeshItem from pymead.gui.analysis_graph import AnalysisGraph from pymead.gui.parameter_tree import MEAParamTree @@ -245,8 +246,29 @@ def __init__(self, path=None, parent=None): self.load_mea_no_dialog(self.path) def closeEvent(self, a0) -> None: - print(f"{a0 = }") - # a0.accept() + """ + Close Event handling for the GUI, allowing changes to be saved before exiting the program. + + Parameters + ========== + a0: QCloseEvent + Qt CloseEvent object + """ + save_dialog = NewMEADialog(parent=self) + exit_dialog = ExitDialog(parent=self) + while True: + if save_dialog.exec_(): # If "Yes" to "Save Changes," + if self.mea.file_name is not None: # If the changes were saved successfully, close the program. + break + else: + if exit_dialog.exec_(): # Otherwise, If "Yes" to "Exit the Program Anyway," close the program. + break + if save_dialog.reject_changes: # If "No" to "Save Changes," close the program. + break + else: # If "Cancel" to "Save Changes," end the CloseEvent and keep the program running. + a0.ignore() + break + # TODO: Make an "abort" button for optimization def on_tab_closed(self, name: str, event: QCloseEvent): @@ -263,8 +285,6 @@ def on_tab_closed(self, name: str, event: QCloseEvent): self.parallel_coords_graph = None elif name == "Cp": self.Cp_graph = None - # TODO: need to do more than just set these to None (closing tab in the middle of optimization forces - # additional data to be plotted in a new, non-dockable window) @pyqtSlot(str) def setStatusBarText(self, message: str): @@ -1612,7 +1632,6 @@ def main(): else: gui = GUI() - # TODO: ask the user to save before closing gui.show() sys.exit(app.exec_()) diff --git a/pymead/gui/input_dialog.py b/pymead/gui/input_dialog.py index 89ffbfed..b235e8f7 100644 --- a/pymead/gui/input_dialog.py +++ b/pymead/gui/input_dialog.py @@ -1859,6 +1859,7 @@ def __init__(self, parent=None): super().__init__(parent=parent) self.setWindowTitle("Save Changes?") self.setFont(self.parent().font()) + self.reject_changes = False buttonBox = QDialogButtonBox(QDialogButtonBox.Yes | QDialogButtonBox.No | QDialogButtonBox.Cancel, self) layout = QFormLayout(self) @@ -1876,7 +1877,24 @@ def yes(self): @pyqtSlot() def no(self): - pass + self.reject_changes = True + + +class ExitDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setWindowTitle("Exit?") + self.setFont(self.parent().font()) + buttonBox = QDialogButtonBox(QDialogButtonBox.Yes | QDialogButtonBox.No, self) + layout = QFormLayout(self) + + label = QLabel("Airfoil not saved.\nAre you sure you want to exit?", parent=self) + + layout.addWidget(label) + layout.addWidget(buttonBox) + + buttonBox.button(QDialogButtonBox.Yes).clicked.connect(self.accept) + buttonBox.button(QDialogButtonBox.No).clicked.connect(self.reject) class EditBoundsDialog(QDialog): diff --git a/pymead/gui/parameter_tree.py b/pymead/gui/parameter_tree.py index bba9e2c3..96ee8f69 100644 --- a/pymead/gui/parameter_tree.py +++ b/pymead/gui/parameter_tree.py @@ -13,6 +13,7 @@ from pymead.gui.selectable_header import SelectableHeaderParameterItem from pymead.gui.airfoil_pos_parameter import AirfoilPositionParameterItem from pymead.gui.input_dialog import FreePointInputDialog, AnchorPointInputDialog, BoundsDialog +from pymead.gui.custom_context_menu_event import custom_context_menu_event from pymead.analysis.single_element_inviscid import single_element_inviscid from pymead.core.airfoil import Airfoil from PyQt5.QtWidgets import QWidget, QGridLayout, QLabel, QHeaderView @@ -33,6 +34,76 @@ # pymead.core.symmetry.symmetry("r") +class AirfoilParameterItem(pTypes.NumericParameterItem): + def __init__(self, param, depth): + self.airfoil_param = None + super().__init__(param, depth) + + def contextMenuEvent(self, ev): + custom_context_menu_event(ev, self) + + def makeWidget(self): + opts = self.param.opts + t = opts['type'] + defs = { + 'value': 0, 'min': None, 'max': None, + 'step': 1.0, 'dec': False, + 'siPrefix': False, 'suffix': '', 'decimals': 12, + } + if t == 'int': + defs['int'] = True + defs['minStep'] = 1.0 + for k in defs: + if k in opts: + defs[k] = opts[k] + if 'limits' in opts: + defs['min'], defs['max'] = opts['limits'] + w = pg.SpinBox() + w.setOpts(**defs) + w.sigChanged = w.sigValueChanged + w.sigChanging = w.sigValueChanging + return w + + +class HeaderParameterItem(pTypes.GroupParameterItem): + + def contextMenuEvent(self, ev): + custom_context_menu_event(ev, self) + + +class CustomParameterItem(pTypes.NumericParameterItem): + + def contextMenuEvent(self, ev): + custom_context_menu_event(ev, self) + + def makeWidget(self): + opts = self.param.opts + t = opts['type'] + defs = { + 'value': 0, 'min': None, 'max': None, + 'step': 1.0, 'dec': False, + 'siPrefix': False, 'suffix': '', 'decimals': 12, + } + if t == 'int': + defs['int'] = True + defs['minStep'] = 1.0 + for k in defs: + if k in opts: + defs[k] = opts[k] + if 'limits' in opts: + defs['min'], defs['max'] = opts['limits'] + w = pg.SpinBox() + w.setOpts(**defs) + w.sigChanged = w.sigValueChanged + w.sigChanging = w.sigValueChanging + return w + + +registerParameterItemType("airfoil_param", AirfoilParameterItem) +registerParameterItemType("header_param", HeaderParameterItem) +registerParameterItemType("custom_param", CustomParameterItem) + + class MEAParameters(pTypes.GroupParameter): """Class for storage of all the Multi-Element Airfoil Parameters.""" def __init__(self, mea: MEA, status_bar, **opts): @@ -40,7 +111,7 @@ def __init__(self, mea: MEA, status_bar, **opts): registerParameterItemType('pos_parameter', AirfoilPositionParameterItem, override=True) opts['type'] = 'bool' opts['value'] = True - pTypes.GroupParameter.__init__(self, **opts) + super().__init__(**opts) self.mea = mea self.status_bar = status_bar self.airfoil_headers = {} @@ -54,7 +125,7 @@ def __init__(self, mea: MEA, status_bar, **opts): 'activate': 'Activate parameter', 'setbounds': 'Set parameter bounds'}) else: - pg_param = Parameter.create(name=k, type='float', value=v.value, removable=True, + pg_param = Parameter.create(name=k, type='custom_param', value=v.value, removable=True, renamable=True, context={'add_eq': 'Define by equation', 'deactivate': 'Deactivate parameter', 'activate': 'Activate parameter', @@ -76,30 +147,29 @@ def add_airfoil(self, airfoil: Airfoil): header_params = ['Base', 'AnchorPoints', 'FreePoints'] for hp in header_params: # print(f"children = {self.airfoil_headers[idx].children()}") - self.airfoil_headers[airfoil.tag].addChild(HeaderParameter(name=hp, type='bool', value=True)) + self.airfoil_headers[airfoil.tag].addChild(dict(name=hp, type='header_param', value=True)) for p_key, p_val in self.mea.param_dict[airfoil.tag]['Base'].items(): - pg_param = AirfoilParameter(self.mea.param_dict[airfoil.tag]['Base'][p_key], - name=f"{airfoil.tag}.Base.{p_key}", - type='float', + pg_param = Parameter.create(name=f"{airfoil.tag}.Base.{p_key}", + type='airfoil_param', value=self.mea.param_dict[airfoil.tag]['Base'][ p_key].value, context={'add_eq': 'Define by equation', 'deactivate': 'Deactivate parameter', 'activate': 'Activate parameter', 'setbounds': 'Set parameter bounds'}) + pg_param.airfoil_param = self.mea.param_dict[airfoil.tag]['Base'][p_key] self.airfoil_headers[airfoil.tag].children()[0].addChild(pg_param) pg_param.airfoil_param.name = pg_param.name() # print(f"param_dict = {self.mea.param_dict}") for ap_key, ap_val in self.mea.param_dict[airfoil.tag]['AnchorPoints'].items(): self.child(airfoil.tag).child('AnchorPoints').addChild( - HeaderParameter(name=ap_key, type='bool', value='true', context={'remove_ap': 'Remove AnchorPoint'})) + dict(name=ap_key, type='header_param', value='true', context={'remove_ap': 'Remove AnchorPoint'})) for p_key, p_val in self.mea.param_dict[airfoil.tag]['AnchorPoints'][ap_key].items(): if p_key != 'xy': - self.child(airfoil.tag).child('AnchorPoints').child(ap_key).addChild(AirfoilParameter( - self.mea.param_dict[airfoil.tag]['AnchorPoints'][ap_key][p_key], - name=f"{airfoil.tag}.AnchorPoints.{ap_key}.{p_key}", type='float', + pg_param = Parameter.create(name=f"{airfoil.tag}.AnchorPoints.{ap_key}.{p_key}", type='airfoil_param', value=self.mea.param_dict[airfoil.tag]['AnchorPoints'][ap_key][ p_key].value, context={'add_eq': 'Define by equation', 'deactivate': 'Deactivate parameter', - 'activate': 'Activate parameter', 'setbounds': 'Set parameter bounds'})) + 'activate': 'Activate parameter', 'setbounds': 'Set parameter bounds'}) + pg_param.airfoil_param = self.mea.param_dict[airfoil.tag]['AnchorPoints'][ap_key][p_key] else: airfoil_param = self.mea.param_dict[airfoil.tag]['AnchorPoints'][ap_key][p_key] pg_param = Parameter.create(name=f"{airfoil.tag}.AnchorPoints.{ap_key}.{p_key}", @@ -116,10 +186,10 @@ def add_airfoil(self, airfoil: Airfoil): pg_param.setValue([pg_param.airfoil_param.value[0], pg_param.airfoil_param.value[1]]) for ap_key, ap_val in self.mea.param_dict[airfoil.tag]['FreePoints'].items(): self.child(airfoil.tag).child('FreePoints').addChild( - HeaderParameter(name=ap_key, type='bool', value='true')) + dict(name=ap_key, type='header_param', value='true')) for fp_key, fp_val in ap_val.items(): self.child(airfoil.tag).child('FreePoints').child(ap_key).addChild( - HeaderParameter(name=fp_key, type='bool', value='true', context={'remove_fp': 'Remove FreePoint'})) + dict(name=fp_key, type='header_param', value='true', context={'remove_fp': 'Remove FreePoint'})) for p_key, p_val in fp_val.items(): airfoil_param = self.mea.param_dict[airfoil.tag]['FreePoints'][ap_key][fp_key][p_key] pg_param = Parameter.create(name=f"{airfoil.tag}.FreePoints.{ap_key}.{fp_key}.{p_key}", @@ -137,18 +207,18 @@ def add_airfoil(self, airfoil: Airfoil): # print(f"{self.child(airfoil.tag).child('FreePoints').child(ap_key).child(fp_key).children() = }") -class AirfoilParameter(pTypes.SimpleParameter): - """Subclass of SimpleParameter which adds the airfoil_param attribute (a `pymead.core.param.Param`).""" - def __init__(self, airfoil_param: Param, **opts): - self.airfoil_param = airfoil_param - pTypes.SimpleParameter.__init__(self, **opts) - - -class HeaderParameter(pTypes.GroupParameter): - """Simple class for containing Parameters with no value. HeaderParameter has a similar purpose to a key in a - nested dictionary.""" - def __init__(self, **opts): - pTypes.GroupParameter.__init__(self, **opts) +# class AirfoilParameter(pTypes.SimpleParameter): +# """Subclass of SimpleParameter which adds the airfoil_param attribute (a `pymead.core.param.Param`).""" +# def __init__(self, airfoil_param: Param, **opts): +# self.airfoil_param = airfoil_param +# pTypes.SimpleParameter.__init__(self, **opts) +# +# +# class HeaderParameter(pTypes.GroupParameter): +# """Simple class for containing Parameters with no value. HeaderParameter has a similar purpose to a key in a +# nested dictionary.""" +# def __init__(self, **opts): +# pTypes.GroupParameter.__init__(self, **opts) class PlotGroup(pTypes.GroupParameter): @@ -176,7 +246,7 @@ def addNew(self, typ): default_name = f"CustomParam{(len(self.childs) + 1)}" if typ == 'Param': airfoil_param = Param(default_value) - pg_param = Parameter.create(name=default_name, type='float', value=default_value, removable=True, + pg_param = Parameter.create(name=default_name, type='custom_param', value=default_value, removable=True, renamable=True, context={'add_eq': 'Define by equation', 'deactivate': 'Deactivate parameter', 'activate': 'Activate parameter', @@ -400,7 +470,6 @@ def get_list_of_vals_from_dict(d): # Adding a FreePoint if change == 'contextMenu' and data == 'add_fp': self.add_free_point(param) - # TODO: for all ParameterTree context menus: inherit style from parent # Adding an AnchorPoint if change == 'contextMenu' and data == 'add_ap': @@ -694,9 +763,9 @@ def add_free_point(self, pg_param: Parameter): self.mea.airfoils[a_tag].airfoil_graph.setData(pos=pos, adj=adj, size=8, pxMode=True, symbol=symbols) if fp.anchor_point_tag not in [p.name() for p in self.params[-1].child(a_tag).child('FreePoints')]: self.params[-1].child(a_tag).child('FreePoints').addChild( - HeaderParameter(name=fp.anchor_point_tag, type='bool', value='true')) + dict(name=fp.anchor_point_tag, type='header_param', value='true')) self.params[-1].child(a_tag).child('FreePoints').child(fp.anchor_point_tag).addChild( - HeaderParameter(name=fp.tag, type='bool', value='true', context={'remove_fp': 'Remove FreePoint'})) + dict(name=fp.tag, type='header_param', value='true', context={'remove_fp': 'Remove FreePoint'})) for p_key, p_val in self.mea.param_dict[a_tag]['FreePoints'][fp.anchor_point_tag][fp.tag].items(): airfoil_param = self.mea.param_dict[a_tag]['FreePoints'][fp.anchor_point_tag][fp.tag][p_key] pg_param = Parameter.create(name=f"{a_tag}.FreePoints.{fp.anchor_point_tag}.{fp.tag}.{p_key}", @@ -760,20 +829,21 @@ def add_anchor_point(self, pg_param: Parameter): # Add the appropriate Headers to the ParameterTree: self.params[-1].child(a_tag).child('AnchorPoints').addChild( - HeaderParameter(name=ap.tag, type='bool', value='true', context={'remove_ap': 'Remove AnchorPoint'})) + dict(name=ap.tag, type='header_param', value='true', context={'remove_ap': 'Remove AnchorPoint'})) self.params[-1].child(a_tag).child('FreePoints').addChild( - HeaderParameter(name=ap.tag, type='bool', value='true')) + dict(name=ap.tag, type='header_param', value='true')) # Add the appropriate Parameters to the ParameterTree: for p_key, p_val in self.mea.param_dict[a_tag]['AnchorPoints'][ap.tag].items(): if p_key != 'xy': - self.params[-1].child(a_tag).child('AnchorPoints').child(ap.tag).addChild(AirfoilParameter( - self.mea.param_dict[a_tag]['AnchorPoints'][ap.tag][p_key], - name=f"{a_tag}.AnchorPoints.{ap.tag}.{p_key}", type='float', + pg_param = Parameter.create( + name=f"{a_tag}.AnchorPoints.{ap.tag}.{p_key}", type='airfoil_param', value=self.mea.param_dict[a_tag]['AnchorPoints'][ap.tag][ p_key].value, context={'add_eq': 'Define by equation', 'deactivate': 'Deactivate parameter', - 'activate': 'Activate parameter', 'setbounds': 'Set parameter bounds'})) + 'activate': 'Activate parameter', 'setbounds': 'Set parameter bounds'} + ) + pg_param.airfoil_param = self.mea.param_dict[a_tag]['AnchorPoints'][ap.tag][p_key] else: airfoil_param = self.mea.param_dict[a_tag]['AnchorPoints'][ap.tag][p_key] pg_param = Parameter.create(name=f"{a_tag}.AnchorPoints.{ap.tag}.{p_key}", @@ -920,11 +990,11 @@ def selectionChanged(self, *args): # Emit signals required for symmetry enforcement param = sel[-1].param parent = sel[-1].param.parent() - if (parent and isinstance(parent, CustomGroup)) or isinstance(param, HeaderParameter): + if (parent and isinstance(parent, CustomGroup)) or param.type == "header_param": self.sigSymmetry.emit(self.get_full_param_name_path(param)) self.sigPosConstraint.emit(self.get_full_param_name_path(param)) self.sigSelChanged.emit((self.get_full_param_name_path(param), param.value())) - elif isinstance(param, AirfoilParameter): + elif param.type == "airfoil_param": self.sigSymmetry.emit(f"${param.name()}") self.sigPosConstraint.emit(f"${param.name()}") self.sigSelChanged.emit((f"${param.name()}", param.value())) diff --git a/pymead/gui/selectable_header.py b/pymead/gui/selectable_header.py index 5fc39648..f4af8ac8 100644 --- a/pymead/gui/selectable_header.py +++ b/pymead/gui/selectable_header.py @@ -1,5 +1,11 @@ from pyqtgraph.parametertree.parameterTypes import GroupParameterItem from pyqtgraph import mkPen +from PyQt5 import QtCore +from PyQt5.QtWidgets import QMenu + +translate = QtCore.QCoreApplication.translate + +from pymead.gui.custom_context_menu_event import custom_context_menu_event class SelectableHeaderParameterItem(GroupParameterItem): @@ -17,3 +23,6 @@ def selected(self, sel): else: for curve in self.param.parent().mea.airfoils[self.param.name()].curve_list: curve.pg_curve_handle.setPen(mkPen(color='cornflowerblue', width=2)) + + def contextMenuEvent(self, ev): + custom_context_menu_event(ev, self)