From feb8e07388a55fb10c1e4075424981d6ffac10d3 Mon Sep 17 00:00:00 2001 From: Dusty Reichwein Date: Fri, 26 Jul 2024 13:41:37 -0700 Subject: [PATCH 01/12] Remove stand alone pypeit_setup_gui script. --- doc/api/pypeit.scripts.setup_gui.rst | 8 ---- pypeit/scripts/setup.py | 28 ++++---------- pypeit/scripts/setup_gui.py | 55 ---------------------------- pypeit/setup_gui/controller.py | 45 ++++++++++------------- pypeit/setup_gui/dialog_helpers.py | 6 +-- pypeit/setup_gui/model.py | 14 +++++-- setup.cfg | 1 - 7 files changed, 40 insertions(+), 117 deletions(-) delete mode 100644 doc/api/pypeit.scripts.setup_gui.rst delete mode 100644 pypeit/scripts/setup_gui.py diff --git a/doc/api/pypeit.scripts.setup_gui.rst b/doc/api/pypeit.scripts.setup_gui.rst deleted file mode 100644 index 6242e966e6..0000000000 --- a/doc/api/pypeit.scripts.setup_gui.rst +++ /dev/null @@ -1,8 +0,0 @@ -pypeit.scripts.setup\_gui module -================================ - -.. automodule:: pypeit.scripts.setup_gui - :members: - :private-members: - :undoc-members: - :show-inheritance: diff --git a/pypeit/scripts/setup.py b/pypeit/scripts/setup.py index 9de645baa3..65e07dc9ef 100644 --- a/pypeit/scripts/setup.py +++ b/pypeit/scripts/setup.py @@ -5,6 +5,7 @@ .. include:: ../include/links.rst """ import argparse +from datetime import datetime, timezone from IPython import embed from pypeit.scripts import scriptbase @@ -77,9 +78,6 @@ def main(args): from pypeit.pypeitsetup import PypeItSetup from pypeit.calibrations import Calibrations - # Set the verbosity, and create a logfile if verbosity == 2 - msgs.set_logfile_and_verbosity('setup', args.verbosity) - if args.spectrograph is None: if args.gui is False: raise IOError('spectrograph is a required argument. Use the -s, --spectrograph ' @@ -93,24 +91,12 @@ def main(args): 'on how to add a new instrument.') if args.gui: - from pypeit.scripts.setup_gui import SetupGUI - # Build up arguments to the GUI - setup_gui_argv = ["-e", args.extension] - if args.spectrograph is not None: - setup_gui_argv += ["-s", args.spectrograph] - - # Pass root but only if there's a spectrograph, because - # root has a default value but can't be acted upon by the GUI - # without a spectrograph. - if isinstance(args.root,list): - root_args = args.root - else: - # If the root argument is a single string, convert it to a list. - # This can happen when the default for --root is used - root_args = [args.root] - setup_gui_argv += ["-r"] + root_args - gui_args = SetupGUI.parse_args(setup_gui_argv) - SetupGUI.main(gui_args) + # Start the GUI + from pypeit.setup_gui.controller import SetupGUIController + gui = SetupGUIController(args.verbosity, args.spectrograph, args.root, args.extension) + gui.start() + else: + msgs.set_logfile_and_verbosity("setup", args.verbosity) # Initialize PypeItSetup based on the arguments ps = PypeItSetup.from_file_root(args.root, args.spectrograph, extension=args.extension) diff --git a/pypeit/scripts/setup_gui.py b/pypeit/scripts/setup_gui.py deleted file mode 100644 index d864eb0372..0000000000 --- a/pypeit/scripts/setup_gui.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -An interactive GUI for creaing pypeit input files. - -.. include common links, assuming primary doc root is up one directory -.. include:: ../include/links.rst -""" -import sys -import os -from qtpy.QtWidgets import QApplication - -from pypeit.scripts import scriptbase -from pypeit.setup_gui.controller import SetupGUIController -from pypeit.spectrographs import available_spectrographs -class SetupGUI(scriptbase.ScriptBase): - - @classmethod - def get_parser(cls, width=None): - parser = super().get_parser(description="Interactive GUI for creating and editing PypeIt input files. " - "Additional Qt arguments can also be used. See https://doc.qt.io/qt-5/qapplication.html#QApplication", - width=width) - parser.add_argument('-s', '--spectrograph', default=None, type=str, - help='A valid spectrograph identifier: {0}'.format( - ', '.join(available_spectrographs))) - parser.add_argument('-r', '--root', default=[], type=str,nargs='+', - help='Root to search for data files. You can provide the top-level ' - 'directory (e.g., /data/Kast) or the search string up through ' - 'the wildcard (.e.g, /data/Kast/b). Use the --extension option ' - 'to set the types of files to search for. Default is the ' - 'current working directory.') - parser.add_argument('-e', '--extension', default='.fits', - help='File extension; compression indicators (e.g. .gz) not required.') - parser.add_argument('-l', '--logfile', type=str, default=None, - help="Write the PypeIt logs to the given file. If the file exists it will be renamed.") - parser.add_argument('-v', '--verbosity', type=int, default=2, - help='Verbosity level between 0 [none] and 2 [all]. Default: 2.') - return parser - - @classmethod - def parse_args(cls, options=None): - """ - Parse the command-line arguments. - """ - parser = cls.get_parser() - # Use parse_known args so we can pass the remainder over to Qt - return parser.parse_known_args() if options is None else parser.parse_known_args(options) - - @staticmethod - def main(combined_args): - - args = combined_args[0] - # Set the Qt Arguments. Note QT expects the program name as arg 0 - qt_args = [sys.argv[0]] + combined_args[1] - app = QApplication(qt_args) - controller = SetupGUIController(args) - controller.start(app) diff --git a/pypeit/setup_gui/controller.py b/pypeit/setup_gui/controller.py index 9bc629989c..497668e051 100644 --- a/pypeit/setup_gui/controller.py +++ b/pypeit/setup_gui/controller.py @@ -26,14 +26,13 @@ from qtpy.QtCore import QObject, Qt, QThread from qtpy.QtGui import QKeySequence -from qtpy.QtWidgets import QAction +from qtpy.QtWidgets import QAction, QApplication from pypeit.setup_gui.view import SetupGUIMainWindow, PypeItFileView, DialogResponses from pypeit.setup_gui.text_viewer import TextViewerWindow from pypeit.setup_gui.dialog_helpers import prompt_to_save, display_error, FileDialog, FileType from pypeit.setup_gui.model import PypeItSetupGUIModel, ModelState, PypeItFileModel from pypeit import msgs - from pypeit.display import display from pypeit import io as pypeit_io @@ -596,39 +595,39 @@ class SetupGUIController(QObject): and performing actions requested by the user. Args: - args (:class:`argparse.Namespace`): The non-QT command line arguments used to start the GUI. + logfile () """ main_window = None model = PypeItSetupGUIModel() - def __init__(self, args): + def __init__(self, verbosity : int, spectrograph : str|None, root : list[str]|str|None, extension : str|None): super().__init__() + # Note QT expects the program name as arg 0 + self.app = QApplication(sys.argv) + QCoreApplication.setOrganizationName("PypeIt") QCoreApplication.setApplicationName("SetupGUI") QCoreApplication.setOrganizationDomain("pypeit.readthedocs.io") - if args.logfile is not None: - logpath = Path(args.logfile) - if logpath.exists(): - timestamp = datetime.now(__UTC__).strftime("%Y%m%d-%H%M%S") - old_log=logpath.parent / (logpath.stem + f".{timestamp}" + logpath.suffix) - logpath.rename(old_log) - self.model.setup_logging(args.logfile, verbosity=args.verbosity) - if args.spectrograph is not None: - self.model.obslog_model.set_spectrograph(args.spectrograph) - if args.root is not None: - for root_dir in args.root: - self.model.obslog_model.add_raw_data_directory(root_dir) - if args.spectrograph is not None and args.root is not None: + self.model.setup_logging(verbosity=verbosity) + if spectrograph is not None: + self.model.obslog_model.set_spectrograph(spectrograph) + if isinstance(root,list): + for root_dir in root: + self.model.obslog_model.add_raw_data_directory(root_dir) + elif root is not None: + self.model.obslog_model.add_raw_data_directory(root) + + if spectrograph is not None and root is not None: self.run_setup_at_startup = True else: self.run_setup_at_startup = False - self.model.obslog_model.default_extension = args.extension + self.model.obslog_model.default_extension = extension SetupGUIController.main_window = SetupGUIMainWindow(self.model, self) self.operation_thread = OperationThread() @@ -650,16 +649,10 @@ def getPypeItFileController(self, model): """ return PypeItFileController(model) - def start(self, app): + def start(self): """ Starts the PypeItSetupGUi event loop. Exits the GUI when the GUI is closed. - - Args: - app (QApplication): The Qt application object for the GUI. The caller is expected - to pass any Qt specific command line arguments to this object - before calling start(). """ - self.app = app self.main_window.show() if self.run_setup_at_startup: self.run_setup() @@ -676,7 +669,7 @@ def start(self, app): timer = QTimer() timer.start(500) timer.timeout.connect(lambda: None) - sys.exit(app.exec_()) + sys.exit(self.app.exec_()) def save_all(self): """" diff --git a/pypeit/setup_gui/dialog_helpers.py b/pypeit/setup_gui/dialog_helpers.py index fba65babcc..efed49699f 100644 --- a/pypeit/setup_gui/dialog_helpers.py +++ b/pypeit/setup_gui/dialog_helpers.py @@ -81,7 +81,7 @@ class DialogResponses(enum.Enum): class FileDialog: - """Opens a file dialog for either opening or saving files. This encapsulates + r"""Opens a file dialog for either opening or saving files. This encapsulates the logic to use QFileDialog in a way that can be used by the view or controller and can be easilly patched by unit tests. @@ -178,12 +178,12 @@ def show(self) -> DialogResponses: @classmethod def create_open_file_dialog(cls, parent : QWidget, caption : str, file_type : FileType, history_group : str = "OpenFile") -> FileDialog: - """Creates a dialog to prompt the user for an existing file. + r"""Creates a dialog to prompt the user for an existing file. Args: parent: The parent widget of the pop-up dialog caption: A caption for the dialog - filter: A QFileDialog filter for a file to open. For example: "Text Files (`*`.txt)" + filter: A QFileDialog filter for a file to open. For example: "Text Files (\*.txt)" history_group: The group in the applications persistent settings to persist the history. Defaults to "OpenFile" diff --git a/pypeit/setup_gui/model.py b/pypeit/setup_gui/model.py index feacb41169..5e4e3dff89 100644 --- a/pypeit/setup_gui/model.py +++ b/pypeit/setup_gui/model.py @@ -8,7 +8,6 @@ import os from collections import deque -import copy import traceback import enum import glob @@ -20,6 +19,7 @@ from qtpy.QtCore import QAbstractTableModel, QAbstractItemModel, QAbstractListModel, QModelIndex, Qt, Signal, QObject, QThread, QStringListModel import qtpy from configobj import ConfigObj +from datetime import datetime, timezone from pypeit import msgs, spectrographs from pypeit.spectrographs import available_spectrographs @@ -1218,7 +1218,7 @@ def __init__(self): self.obslog_model = PypeItObsLogModel() self._clipboard = PypeItMetadataModel(None) - def setup_logging(self, logname, verbosity): + def setup_logging(self, verbosity): """ Setup the PypeIt logging mechanism and a log buffer for monitoring the progress of operations and @@ -1228,7 +1228,15 @@ def setup_logging(self, logname, verbosity): logname (str): The filename to log to. verbosity (int): The verbosity level to log at. """ - self.log_buffer = LogBuffer(logname,verbosity) + + if verbosity >=2: + # For conistency with other pypeit scripts, log to a file when verbosity is 2 + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M") + logfile = f"setup_gui_{timestamp}.log" + else: + logfile = None + + self.log_buffer = LogBuffer(logfile,verbosity) msgs.reset(verbosity=verbosity, log=self.log_buffer, log_to_stderr=False) msgs.info(f"QT Version: {qtpy.QT_VERSION}") msgs.info(f"PySide version: {qtpy.PYSIDE_VERSION}") diff --git a/setup.cfg b/setup.cfg index 71821b46a4..8e6f7b7cf7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -168,7 +168,6 @@ console_scripts = pypeit_show_2dspec = pypeit.scripts.show_2dspec:Show2DSpec.entry_point pypeit_skysub_regions = pypeit.scripts.skysub_regions:SkySubRegions.entry_point pypeit_view_fits = pypeit.scripts.view_fits:ViewFits.entry_point - pypeit_setup_gui = pypeit.scripts.setup_gui:SetupGUI.entry_point ginga.rv.plugins = SlitWavelength = pypeit.display:setup_SlitWavelength From 71fe9055acc06e2b2e8c538323a6a0ca413d9be4 Mon Sep 17 00:00:00 2001 From: Dusty Reichwein Date: Tue, 30 Jul 2024 17:46:02 -0700 Subject: [PATCH 02/12] Make font default to minimum size of 12 pt, use monospace font in file metadata, allow resizing of portions of obslog and file tabs. --- pypeit/setup_gui/controller.py | 10 +- pypeit/setup_gui/view.py | 282 ++++++++++++++++++++++++--------- 2 files changed, 214 insertions(+), 78 deletions(-) diff --git a/pypeit/setup_gui/controller.py b/pypeit/setup_gui/controller.py index 497668e051..10c33f276d 100644 --- a/pypeit/setup_gui/controller.py +++ b/pypeit/setup_gui/controller.py @@ -602,7 +602,7 @@ class SetupGUIController(QObject): main_window = None model = PypeItSetupGUIModel() - def __init__(self, verbosity : int, spectrograph : str|None, root : list[str]|str|None, extension : str|None): + def __init__(self, verbosity : int, spectrograph : str|None=None, root : list[str]|str|None=None, extension : str|None=".fits"): super().__init__() # Note QT expects the program name as arg 0 @@ -628,6 +628,14 @@ def __init__(self, verbosity : int, spectrograph : str|None, root : list[str]|st self.run_setup_at_startup = False self.model.obslog_model.default_extension = extension + + defaultFont = self.app.font() + msgs.info(f"Default font pixel size: {defaultFont.pixelSize()}") + msgs.info(f"Default font point size: {defaultFont.pointSizeF()}") + if defaultFont.pointSizeF() < 12.0: + msgs.info(f"Setting font to 12.") + defaultFont.setPointSize(12) + self.app.setFont(defaultFont) SetupGUIController.main_window = SetupGUIMainWindow(self.model, self) self.operation_thread = OperationThread() diff --git a/pypeit/setup_gui/view.py b/pypeit/setup_gui/view.py index ac0e3613db..57a5316956 100644 --- a/pypeit/setup_gui/view.py +++ b/pypeit/setup_gui/view.py @@ -8,7 +8,7 @@ from qtpy.QtWidgets import QGroupBox, QHBoxLayout, QVBoxLayout, QComboBox, QToolButton, QFileDialog, QWidget, QGridLayout, QFormLayout from qtpy.QtWidgets import QMessageBox, QTabWidget, QTreeView, QLayout, QLabel, QScrollArea, QListView, QTableView, QPushButton, QStyleOptionButton, QProgressDialog, QDialog, QHeaderView, QSizePolicy, QCheckBox, QDialog -from qtpy.QtWidgets import QAction, QAbstractItemView, QStyledItemDelegate, QButtonGroup, QStyle, QTabBar,QAbstractItemDelegate +from qtpy.QtWidgets import QAction, QSplitter, QAbstractItemView, QStyledItemDelegate, QButtonGroup, QStyle, QTabBar,QAbstractItemDelegate from qtpy.QtGui import QIcon,QMouseEvent, QKeySequence, QPalette, QColor, QValidator, QFont, QFontDatabase, QFontMetrics, QTextCharFormat, QTextCursor from qtpy.QtCore import Qt, QObject, QSize, Signal,QSettings, QStringListModel, QAbstractItemModel, QModelIndex, QMargins, QSortFilterProxyModel, QRect @@ -20,7 +20,7 @@ from pypeit import msgs def debugSizeStuff(widget:QWidget, name="widget"): - """Helper method for logging sizxing information about a wdiget and its layout.""" + """Helper method for logging sizxing information about a wdiget and its layout.""" msgs.info(f"{name} (width/height): {widget.width()}/{widget.height()} geometry x/y/w/h: {widget.geometry().x()}/{widget.geometry().y()}/{widget.geometry().width()}/{widget.geometry().height()} min w/h {widget.minimumWidth()}/{widget.minimumHeight()} hint w/h {widget.sizeHint().width()}/{widget.sizeHint().height()} min hint w/h {widget.minimumSizeHint().width()}/{widget.minimumSizeHint().height()} cm tlbr: {widget.contentsMargins().top()}/{widget.contentsMargins().left()}/{widget.contentsMargins().bottom()}/{widget.contentsMargins().right()} frame w/h {widget.frameSize().width()}/{widget.frameSize().height()}") layout = widget.layout() if layout is None: @@ -28,6 +28,12 @@ def debugSizeStuff(widget:QWidget, name="widget"): else: msgs.info(f"{name} layout size constraint {layout.sizeConstraint()} spacing: {layout.spacing()} cm: tlbr {layout.contentsMargins().top()}/{layout.contentsMargins().left()}/{layout.contentsMargins().bottom()}/{layout.contentsMargins().right()} totalMinSize (w/h): {layout.totalMinimumSize().width()}/{layout.totalMinimumSize().width()} totalMaxSize (w/h): {layout.totalMaximumSize().width()}/{layout.totalMaximumSize().width()} totalHint (w/h): {layout.totalSizeHint().width()}/{layout.totalSizeHint().width()}") + fm = widget.fontMetrics() + if fm is None: + msgs.info(f"{name} fm is None") + else: + msgs.info(f"{name} fm lineSpacing: {fm.lineSpacing()} maxWidth: {fm.maxWidth()}, avg width: {fm.averageCharWidth()}") + def calculateButtonMinSize(button_widget : QPushButton) -> QSize: """Calculates and sets the minimum size of a budget widget @@ -519,20 +525,8 @@ def __init__(self, parent, model, controller): self.setItemDelegate(PypeItCustomEditorDelegate(parent=self)) self.setModel(model) - # Set to a minimum number of rows high so the frame type editor has enough space - if model.rowCount() > 0: - row_height = self.verticalHeader().sectionSize(0) - else: - row_height = self.verticalHeader().defaultSectionSize() - - # We use 11 rows, 1 for the header, and 10 data rows. This seems to give an adaquate buffer to the frame type editor. - min_height = (self.contentsMargins().top() + self.contentsMargins().bottom() + - self.horizontalScrollBar().sizeHint().height() + - 11*row_height) - msgs.info(f"current min_height/height/hint h: {self.minimumHeight()}/{self.height()}/{self.sizeHint().height()}, scrollbar hint h {self.horizontalScrollBar().sizeHint().height()}, currentmargin top/bottom: {self.contentsMargins().top()}/{self.contentsMargins().bottom()} hdr min_height/height/hint h: {self.horizontalHeader().minimumHeight()}/{self.horizontalHeader().height()}/{self.horizontalHeader().sizeHint().height()}") - msgs.info(f"rowHeight: {row_height} current min_height {self.minimumHeight()} new min_height {min_height}") - if min_height > self.minimumHeight(): - self.setMinimumHeight(min_height) + font = QFont("Monospace") + self.setFont(font) self.addActions(controller.getActions(self)) self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) @@ -593,7 +587,7 @@ class ConfigValuesPanel(QGroupBox): Args: spec_name (str): Name of spectrograph for the configuration. config (dict): The name/value pairs for the configuration keys defined by the spectrograph. - lines_to_display (int): How many lines to display before scrolling. + lines_to_display (int): How many lines to display initially. parent (QWidget, Optional): The parent widget, defaults to None. """ def __init__(self, spec_name, config, lines_to_display, parent=None): @@ -642,28 +636,34 @@ def __init__(self, spec_name, config, lines_to_display, parent=None): # Figure out the correct height for this panel, so that only the spectrograph and self.number_of_lines # config keys are visible - # Find the minimum height of the form widget needed to hold the number of lines to display + # Find the minimum height of the form widget needed to hold the total number of config lines msgs.info(f"font height: {fm.height()} vertical spacing {self._form_widget_layout.verticalSpacing()}") + self.setMaximumHeight(self.computeHeight(len(self._config_labels))) + + layout.addWidget(self._scroll_area) + + def computeHeight(self, lines_to_display:int) ->int: + """Compute the height needed to display a given number of lines + + Args: + lines_to_display: The number of lines to display + Return: + The vertical size in pixels needed to display the given number of configuration lines + """ + fm = self.fontMetrics() min_fw_height = self._form_widget_layout.verticalSpacing()*(lines_to_display-1) + fm.height()*lines_to_display # The height of this panel is that height plus the margins + the group box title scroll_area_margins = self._scroll_area.contentsMargins() group_box_margins = self.contentsMargins() form_widget_margins = self._form_widget.contentsMargins() - self.setFixedHeight(min_fw_height + - fm.height() + # Group Box Title - group_box_margins.top() + group_box_margins.bottom() + - scroll_area_margins.top() + scroll_area_margins.bottom() + - form_widget_margins.top() + form_widget_margins.bottom()) - - # Set to fixed sizing policy - policy = QSizePolicy() - policy.setHorizontalPolicy(QSizePolicy.Minimum) - policy.setVerticalPolicy(QSizePolicy.Fixed) - policy.setControlType(QSizePolicy.DefaultType) - self.setSizePolicy(policy) - - layout.addWidget(self._scroll_area) + + return (min_fw_height + + fm.height() + # Group Box Title + group_box_margins.top() + group_box_margins.bottom() + + scroll_area_margins.top() + scroll_area_margins.bottom() + + form_widget_margins.top() + form_widget_margins.bottom()) + def setNewValues(self, config_dict: dict) -> None: """Update the panel to display new configuration values. @@ -778,15 +778,50 @@ def __init__(self, model, controller): self.filename_value.setAlignment(Qt.AlignLeft) layout.addWidget(self.filename_value) - # Add the spectrograph configuration keys to the third row, first column - third_row_layout = QHBoxLayout() - layout.addLayout(third_row_layout) + # The third row consists of a splitter, allowing the user to + # decide how much space to divide between the portions of a PypeIt file. + # These are displayed in the same order as in a .pypeit file: + # PypeIt Parameters + # Setup (or Config Valeus) + # Raw data paths + # File metadata - # Add the ConfigValuesPanel, displaying the spectrograph + config keys. - self.config_panel = ConfigValuesPanel(model.spec_name, model.config_values, 5, parent=self) - third_row_layout.addWidget(self.config_panel) - # Add the Raw Data directory panel to the third row, second column + # Create a group box and a tree view for the pypeit parameters + params_group = QGroupBox(self.tr("PypeIt Parameters")) + params_group_layout = QHBoxLayout() + self.params_tree = QTreeView(params_group) + self.params_tree.setModel(model.params_model) + self.params_tree.header().setSectionResizeMode(QHeaderView.ResizeToContents) + self.params_tree.expandToDepth(1) + params_group_layout.addWidget(self.params_tree) + params_group.setLayout(params_group_layout) + fm = params_group.fontMetrics() + pg_cm = params_group.contentsMargins() + pt_cm = self.params_tree.contentsMargins() + + # Compute the initial height to use in the q splitter + pg_init_height = (fm.lineSpacing() + # Title + pg_cm.top() + pg_cm.bottom() + # Group Box margin + pt_cm.top() + pt_cm.bottom() + # Params Tree margin + self.params_tree.header().sizeHint().height() + # Params tree header + 3 * fm.lineSpacing() # desired # of rows + ) + # The minimum height is always the height of the title, so the section can be hidden + # by the user + params_group.setMinimumHeight(fm.lineSpacing()) + + + # Create the ConfigValuesPanel, displaying the spectrograph + config keys. + # We default to displaying only 3 lines of the configuration. + self.config_panel = ConfigValuesPanel(model.spec_name, model.config_values, 3, parent=self) + fm = self.config_panel.fontMetrics() + + config_panel_init_height = self.config_panel.computeHeight(3) + + self.config_panel.setMinimumHeight(fm.lineSpacing()) + + # Create the Raw Data directory panel # This is not editable, because the user can add/remove directories by adding/removing individual # files in the metadata_file_table paths_group = QGroupBox(self.tr("Raw Data Directories"),self) @@ -795,40 +830,83 @@ def __init__(self, model, controller): paths_viewer.setModel(model.paths_model) paths_viewer.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) paths_group_layout.addWidget(paths_viewer) - third_row_layout.addWidget(paths_group) + pg_fm = paths_group.fontMetrics() + pv_fm = paths_viewer.fontMetrics() + pg_cm = paths_group.contentsMargins() + pv_cm = paths_viewer.contentsMargins() - # Make the paths wider than the config values panel - third_row_layout.setStretch(1, 2) + # We set the initial height to be able to display two paths + paths_group_init_height = (pg_fm.lineSpacing() + # title line + pg_cm.top() + pg_cm.bottom() + # group widget margins + pv_cm.top() + pv_cm.bottom() + # list margins + 2 * pv_fm.lineSpacing() ) # Two paths in the list - # Create a group box and a tree view for the pypeit parameters - params_group = QGroupBox(self.tr("PypeIt Parameters")) - params_group_layout = QHBoxLayout() - self.params_tree = QTreeView(params_group) - self.params_tree.setModel(model.params_model) - self.params_tree.header().setSectionResizeMode(QHeaderView.ResizeToContents) - self.params_tree.expandToDepth(1) + paths_group.setMinimumHeight(pg_fm.lineSpacing()) - params_group_layout.addWidget(self.params_tree) - params_group.setLayout(params_group_layout) - layout.addWidget(params_group) - # Create a group box and table view for the file metadata table + # Create a group box and metadata view for the file metadata table file_group = QGroupBox(self.tr("File Metadata")) file_group_layout = QVBoxLayout() self.file_metadata_table = PypeItMetadataView(self, model.metadata_model, controller.getMetadataController(model.metadata_model)) file_group_layout.addWidget(self.file_metadata_table) file_group.setLayout(file_group_layout) - layout.addWidget(file_group) + self.file_group = file_group + fm = file_group.fontMetrics() - - # Stretch the metadata and params rows more than the filename and config_key rows - layout.setStretch(2,4) - layout.setStretch(3,10) - layout.setStretch(4,10) + # The file metadata is allowed to stretch, so its initial height can start as its preferred size + file_group_init_height = file_group.sizeHint().height() - self.model.stateChanged.connect(self.update_from_model) + file_group.setMinimumHeight(fm.lineSpacing()) + + # Create the splitter to separate the four items + splitter = QSplitter(self) + splitter.setOrientation(Qt.Orientation.Vertical) + # Do not allow children to be collapsed beyond their minimimum size, so that a title section + # is always visible + splitter.setChildrenCollapsible(False) + + splitter.addWidget(params_group) + splitter.addWidget(self.config_panel) + splitter.addWidget(paths_group) + splitter.addWidget(file_group) + layout.addWidget(splitter) + + # Set the stretch factors to fixed for everything but the file metadata group + pg_index = splitter.indexOf(params_group) + cfg_index = splitter.indexOf(self.config_panel) + paths_index = splitter.indexOf(paths_group) + fg_index = splitter.indexOf(file_group) + + splitter.setStretchFactor(pg_index, 0) + splitter.setStretchFactor(cfg_index, 0) + splitter.setStretchFactor(paths_index, 0) + splitter.setStretchFactor(fg_index, 1) + + # Set the initial sizes of the four sections within the splitter + splitter.setSizes([pg_init_height,config_panel_init_height,paths_group_init_height,file_group_init_height]) + + # Monitor the model for updates + self.model.stateChanged.connect(self.update_from_model) + def splitterMoved(self, pos, index): + msgs.info(f"splitter moved pos {pos} index {index}") + msgs.info(f"params size/sizeHint/minSizeHint: {self.params_group.size()} / {self.params_group.sizeHint()} / {self.params_group.minimumSizeHint()}") + msgs.info(f"setup size/sizeHint/minSizeHint: {self.config_panel.size()} / {self.config_panel.sizeHint()} / {self.config_panel.minimumSizeHint()}") + msgs.info(f"paths size/sizeHint/minSizeHint: {self.paths_group.size()} / {self.paths_group.sizeHint()} / {self.paths_group.minimumSizeHint()}") + msgs.info(f"metadata size/sizeHint/minSizeHint: {self.file_group.size()} / {self.file_group.sizeHint()} / {self.file_group.minimumSizeHint()}") + msgs.info(f"Splitter sizes: {self.splitter.sizes()}") + #sizes = self.splitter.sizes() + #msgs.info(f"Sizes: {self.splitter.sizes()}") + #pg_index = self.splitter.indexOf(self.params_group) + #if self.params_group.size().height == 0: + # fm = self.params_group.fontMetrics() + # self.params_group.setMinimumHeight(fm.height()) + # self.splitter.setCollapsible(pg_index,False) + # sizes[pg_index] = fm.height() + # self.splitter.setSizes(sizes) + #elif self.splitter.isCollapsible(pg_index) is False and self.params_group.size().height > 0: + # self.splitter.setCollapsible(pg_index,True) def update_from_model(self): """ Signal handler that updates view when the underlying model changes. @@ -879,9 +957,25 @@ def __init__(self, model, controller, parent=None): self._controller = controller layout = QVBoxLayout(self) + + # We use a splitter to separate the spectrograph/raw data paths from the file metadata + # Create the splitter to hold both rows + splitter=QSplitter(self) + splitter.setOrientation(Qt.Orientation.Vertical) + layout.addWidget(splitter) + + # Do not allow children to be collapsed beyond their minimimum size, so that a title section + # is always visible + splitter.setChildrenCollapsible(False) + + # First build a widget to contain the spectrograph/ raw data paths + spec_paths_widget = QWidget() + # Place the spectrograph group box and combo box in the first row, first column - top_row_layout = QHBoxLayout() - layout.addLayout(top_row_layout) + spec_paths_layout = QHBoxLayout(spec_paths_widget) + # No Margins, this is just a container + spec_paths_layout.setContentsMargins(0,0,0,0) + spectrograph_box = QGroupBox(title=self.tr("Spectrograph"), parent=self) spectrograph_layout = QHBoxLayout() @@ -895,7 +989,7 @@ def __init__(self, model, controller, parent=None): spectrograph_layout.addWidget(self.spectrograph) spectrograph_layout.setAlignment(self.spectrograph, Qt.AlignTop) spectrograph_box.setLayout(spectrograph_layout) - top_row_layout.addWidget(spectrograph_box) + spec_paths_layout.addWidget(spectrograph_box) # Create a Group Box to group the paths editor and viewer paths_group = QGroupBox(self.tr("Raw Data Directories"),self) @@ -911,11 +1005,21 @@ def __init__(self, model, controller, parent=None): self._paths_viewer = QListView(paths_group) self._paths_viewer.setModel(model.paths_model) - fm = self.fontMetrics() - # Only display 5 paths - lines =5 - self._paths_viewer.setFixedHeight(fm.height()*lines+self._paths_viewer.spacing()*(lines-1)) paths_group_layout.addWidget(self._paths_viewer) + + # The initial height of the first row in the splitter. The raw data paths will be larger + # so we use its size for the row. We start with it displaying 2 paths + initial_lines = 2 + fm = self.fontMetrics() + viewer_margins = self._paths_viewer.contentsMargins() + path_group_margins = paths_group.contentsMargins() + spec_paths_init_height = (fm.lineSpacing() + # Group titles + path_group_margins.top() + path_group_margins.bottom() + # Groupbox margins + self.paths_editor.sizeHint().height() + # Path editor + paths_group_layout.spacing() + # Gap between editor and viewer + viewer_margins.top() + viewer_margins.bottom() + # viewer margins + fm.height()*initial_lines+self._paths_viewer.spacing()*(initial_lines-1) # Number of lines desired + ) # Add action for removing a path self._paths_viewer.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) @@ -928,21 +1032,45 @@ def __init__(self, model, controller, parent=None): # Add the Raw Data directory panel to the first row, second column - top_row_layout.addWidget(paths_group) + spec_paths_layout.addWidget(paths_group) - # Make the metadata wider than the spectrograph - top_row_layout.setStretch(1, 2) + # Make the raw data paths wider than the spectrograph + spec_paths_layout.setStretch(1, 2) + # The second row consists of a group box containing the metadata view - # Add the File Metadata box in the second row - file_group = QGroupBox(self.tr("File Metadata")) + file_group_widget = QGroupBox(self.tr("File Metadata")) file_group_layout = QHBoxLayout() - self.obslog_table = PypeItMetadataView(file_group, model.metadata_model, controller.getMetadataController(model.metadata_model)) + self.obslog_table = PypeItMetadataView(file_group_widget, model.metadata_model, controller.getMetadataController(model.metadata_model)) file_group_layout.addWidget(self.obslog_table) - file_group.setLayout(file_group_layout) - layout.addWidget(file_group) - # Make File Metadata taller than the spectrograph/raw data paths row - layout.setStretch(1,4) + file_group_widget.setLayout(file_group_layout) + + # The initial height for the second row, which will be allowed to stretch to fill the tab + file_group_init_height = file_group_widget.sizeHint().height() + + # Set minimum height to the height of one line of text for the widgets inside the splitter. + # This prevents the splitter from hiding the titles of each section. + spectrograph_box.setMinimumHeight(spectrograph_box.fontMetrics().lineSpacing()) + paths_group.setMinimumHeight(paths_group.fontMetrics().lineSpacing()) + file_group_widget.setMinimumHeight(file_group_widget.fontMetrics().lineSpacing()) + + # Add the widgets to the splitter, and set the stretch factor such that the metadata will stretch to + # fill the available space, but the spectrograph/raw data paths will only stretch if the user decides to + # resize them. + splitter.addWidget(spec_paths_widget) + splitter.addWidget(file_group_widget) + + spec_paths_index = splitter.indexOf(spec_paths_widget) + file_group_index = splitter.indexOf(file_group_widget) + + splitter.setStretchFactor(spec_paths_index, 0) + splitter.setStretchFactor(file_group_index, 1) + + # Set the initial heights of the splitter children + splitter.setSizes([spec_paths_init_height, file_group_init_height]) + + + # Connect with the model self.setModel(model) # Update model with new spectrograph/data paths From 6a00871e3276bce427539e820dd8ac54d7bac8ea Mon Sep 17 00:00:00 2001 From: Dusty Reichwein Date: Thu, 1 Aug 2024 11:45:28 -0700 Subject: [PATCH 03/12] Support interactive resizing of file metadata columns --- pypeit/setup_gui/view.py | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/pypeit/setup_gui/view.py b/pypeit/setup_gui/view.py index 57a5316956..56b3dc5771 100644 --- a/pypeit/setup_gui/view.py +++ b/pypeit/setup_gui/view.py @@ -9,8 +9,8 @@ from qtpy.QtWidgets import QGroupBox, QHBoxLayout, QVBoxLayout, QComboBox, QToolButton, QFileDialog, QWidget, QGridLayout, QFormLayout from qtpy.QtWidgets import QMessageBox, QTabWidget, QTreeView, QLayout, QLabel, QScrollArea, QListView, QTableView, QPushButton, QStyleOptionButton, QProgressDialog, QDialog, QHeaderView, QSizePolicy, QCheckBox, QDialog from qtpy.QtWidgets import QAction, QSplitter, QAbstractItemView, QStyledItemDelegate, QButtonGroup, QStyle, QTabBar,QAbstractItemDelegate -from qtpy.QtGui import QIcon,QMouseEvent, QKeySequence, QPalette, QColor, QValidator, QFont, QFontDatabase, QFontMetrics, QTextCharFormat, QTextCursor -from qtpy.QtCore import Qt, QObject, QSize, Signal,QSettings, QStringListModel, QAbstractItemModel, QModelIndex, QMargins, QSortFilterProxyModel, QRect +from qtpy.QtGui import QIcon, QKeySequence, QPalette, QColor, QValidator, QFont, QFontDatabase, QFontMetrics, QTextCharFormat, QTextCursor +from qtpy.QtCore import Qt, QEvent, QObject, QSize, Signal,QSettings, QStringListModel, QAbstractItemModel, QModelIndex, QMargins, QSortFilterProxyModel, QRect from pypeit.spectrographs import available_spectrographs @@ -518,8 +518,9 @@ class PypeItMetadataView(QTableView): def __init__(self, parent, model, controller): super().__init__(parent=parent) self._controller=controller + self._shownOnce = False self._controller.setView(self) - self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) + self.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.setItemDelegate(PypeItCustomEditorDelegate(parent=self)) @@ -542,11 +543,38 @@ def setModel(self, model): proxy_model.setSourceModel(model) sort_column = model.getColumnNumFromName('mjd') proxy_model.sort(sort_column, Qt.AscendingOrder) + old_model = self.model() + if old_model is not None: + old_model.modelReset.disconnect(self._handleModelReset) super().setModel(proxy_model) + + # Listen to model resets so we can fix column sizes + proxy_model.modelReset.connect(self._handleModelReset) + # Enable sorting self.setSortingEnabled(True) self.horizontalHeader().setSortIndicator(sort_column, Qt.AscendingOrder) + def _handleModelReset(self): + """ + Fix column and row sizes after the model is reset. + """ + self.resizeColumnsToContents() + self.resizeRowsToContents() + + def showEvent(self, event : QEvent): + """ + Event handler that sets column and row sizes to fit contents + the first time the view is shown. + + Args: + event: The show event (unused) + """ + if not self._shownOnce: + # Fix column and row sizes the first time the view is shown + self.resizeColumnsToContents() + self.resizeRowsToContents() + self._shownOnce=True def selectionChanged(self, selected, deselected): """Event handler called by Qt when a selection change. Overriden from QTableView. From 57aec28c6df4515819d4a1b8674b3c714513386e Mon Sep 17 00:00:00 2001 From: Dusty Reichwein Date: Fri, 2 Aug 2024 16:10:09 -0700 Subject: [PATCH 04/12] Set mouse cursor to indicate editable metadata items. --- pypeit/setup_gui/view.py | 79 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/pypeit/setup_gui/view.py b/pypeit/setup_gui/view.py index 56b3dc5771..82e7288f51 100644 --- a/pypeit/setup_gui/view.py +++ b/pypeit/setup_gui/view.py @@ -8,9 +8,9 @@ from qtpy.QtWidgets import QGroupBox, QHBoxLayout, QVBoxLayout, QComboBox, QToolButton, QFileDialog, QWidget, QGridLayout, QFormLayout from qtpy.QtWidgets import QMessageBox, QTabWidget, QTreeView, QLayout, QLabel, QScrollArea, QListView, QTableView, QPushButton, QStyleOptionButton, QProgressDialog, QDialog, QHeaderView, QSizePolicy, QCheckBox, QDialog -from qtpy.QtWidgets import QAction, QSplitter, QAbstractItemView, QStyledItemDelegate, QButtonGroup, QStyle, QTabBar,QAbstractItemDelegate -from qtpy.QtGui import QIcon, QKeySequence, QPalette, QColor, QValidator, QFont, QFontDatabase, QFontMetrics, QTextCharFormat, QTextCursor -from qtpy.QtCore import Qt, QEvent, QObject, QSize, Signal,QSettings, QStringListModel, QAbstractItemModel, QModelIndex, QMargins, QSortFilterProxyModel, QRect +from qtpy.QtWidgets import QAction, QAbstractItemView, QStyledItemDelegate, QButtonGroup, QStyle, QTabBar,QAbstractItemDelegate, QSplitter +from qtpy.QtGui import QIcon,QCursor, QMouseEvent, QKeySequence, QPalette, QColor, QValidator, QFont, QFontDatabase, QFontMetrics, QTextCharFormat, QTextCursor +from qtpy.QtCore import Qt, QObject, QEvent, QSize, Signal,QSettings, QStringListModel, QAbstractItemModel, QModelIndex, QMargins, QSortFilterProxyModel, QRect from pypeit.spectrographs import available_spectrographs @@ -221,6 +221,10 @@ def __init__(self, parent, allowed_values, index, num_lines): checkbox_container = QWidget() + # Make sure we have a pointer mouse cursor, rather than the cursor + # inherited from the table view + self.setCursor(Qt.ArrowCursor) + # Create the checkboxes for each allowable option self._button_group = QButtonGroup() self._button_group.setExclusive(False) @@ -520,18 +524,81 @@ def __init__(self, parent, model, controller): self._controller=controller self._shownOnce = False self._controller.setView(self) + + # Setup the header row to allow interactive resizing of columns, + # and the header column to resize to contents self.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) + + # Allow selecting rows for copy/paste self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + + # Set the editor for editable columns self.setItemDelegate(PypeItCustomEditorDelegate(parent=self)) + + # Set the model and keep track of what column the mouse is in so the + # cursor can be customized. We also track mouse events on the header + # so that the mouse can be reset when over the header row self.setModel(model) + self.cur_col = "unknown" + self.editable_columns = model.editable_columns + self.horizontalHeader().installEventFilter(self) + + # Set to a minimum number of rows high so the frame type editor has enough space + if model.rowCount() > 0: + row_height = self.verticalHeader().sectionSize(0) + else: + row_height = self.verticalHeader().defaultSectionSize() - font = QFont("Monospace") - self.setFont(font) + # We use 11 rows, 1 for the header, and 10 data rows. This seems to give an adaquate buffer to the frame type editor. + min_height = (self.contentsMargins().top() + self.contentsMargins().bottom() + + self.horizontalScrollBar().sizeHint().height() + + 11*row_height) + msgs.info(f"current min_height/height/hint h: {self.minimumHeight()}/{self.height()}/{self.sizeHint().height()}, scrollbar hint h {self.horizontalScrollBar().sizeHint().height()}, currentmargin top/bottom: {self.contentsMargins().top()}/{self.contentsMargins().bottom()} hdr min_height/height/hint h: {self.horizontalHeader().minimumHeight()}/{self.horizontalHeader().height()}/{self.horizontalHeader().sizeHint().height()}") + msgs.info(f"rowHeight: {row_height} current min_height {self.minimumHeight()} new min_height {min_height}") + if min_height > self.minimumHeight(): + self.setMinimumHeight(min_height) self.addActions(controller.getActions(self)) self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.setMouseTracking(True) + + def mouseMoveEvent(self, event : QMouseEvent): + """Track which column the mouse cursor is over, and set the + cursor to an I-Beam shape when over an editable column + """ + pos = event.position() + col= self.columnAt(int(pos.x())) + if col == -1: + name="unknown" + else: + name = self.model().headerData(col, Qt.Orientation.Horizontal) + + # Set/Unset the cursor to indicate an editable column when the + # cursor enters a new column + if name != self.cur_col: + self.cur_col=name + if name in self.editable_columns: + self.setCursor(Qt.IBeamCursor) + else: + self.unsetCursor() + + def eventFilter(self, watched : QObject, event : QEvent): + """Event filter that resets the mouse cursor when the mosue + is over the table header + """ + # We don't get a mouse leave event from the table until the + # mouse leaves the entire TableView, but we don't get mouseMove + # events when the mouse is over the header. So wait for an Enter + # event on the header row to unset any cursor set by + # mousing over an editable column. + # + if event.type() == QEvent.Enter: + self.cur_col = "unknown" + self.unsetCursor() + return False + def setModel(self, model): """Set the PypeItMetadataProxy model to use for the table. @@ -586,6 +653,8 @@ def selectionChanged(self, selected, deselected): super().selectionChanged(selected, deselected) self.selectionUpdated.emit() + + def selectedRows(self): """ Return which rows in the PypeItMetadataPorxy model are selected. From e9d4ee326fc81d46ea50319c7faa6f85706778f0 Mon Sep 17 00:00:00 2001 From: Dusty Reichwein Date: Tue, 6 Aug 2024 18:21:09 -0700 Subject: [PATCH 05/12] Add help button --- pypeit/setup_gui/view.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/pypeit/setup_gui/view.py b/pypeit/setup_gui/view.py index 82e7288f51..60fa066388 100644 --- a/pypeit/setup_gui/view.py +++ b/pypeit/setup_gui/view.py @@ -9,8 +9,8 @@ from qtpy.QtWidgets import QGroupBox, QHBoxLayout, QVBoxLayout, QComboBox, QToolButton, QFileDialog, QWidget, QGridLayout, QFormLayout from qtpy.QtWidgets import QMessageBox, QTabWidget, QTreeView, QLayout, QLabel, QScrollArea, QListView, QTableView, QPushButton, QStyleOptionButton, QProgressDialog, QDialog, QHeaderView, QSizePolicy, QCheckBox, QDialog from qtpy.QtWidgets import QAction, QAbstractItemView, QStyledItemDelegate, QButtonGroup, QStyle, QTabBar,QAbstractItemDelegate, QSplitter -from qtpy.QtGui import QIcon,QCursor, QMouseEvent, QKeySequence, QPalette, QColor, QValidator, QFont, QFontDatabase, QFontMetrics, QTextCharFormat, QTextCursor -from qtpy.QtCore import Qt, QObject, QEvent, QSize, Signal,QSettings, QStringListModel, QAbstractItemModel, QModelIndex, QMargins, QSortFilterProxyModel, QRect +from qtpy.QtGui import QIcon,QDesktopServices, QMouseEvent, QKeySequence, QPalette, QColor, QValidator, QFont, QFontDatabase, QFontMetrics, QTextCharFormat, QTextCursor +from qtpy.QtCore import Qt, QUrl, QObject, QEvent, QSize, Signal,QSettings, QStringListModel, QAbstractItemModel, QModelIndex, QMargins, QSortFilterProxyModel, QRect from pypeit.spectrographs import available_spectrographs @@ -1542,6 +1542,15 @@ def _logClosed(self): """Signal handler that clears the log window when it closes.""" self._logWindow = None + def _helpButton(self): + """Signal handler that responds to the help button being pressed.""" + + result = QDesktopServices.openUrl(QUrl("https://pypeit.readthedocs.io/en/latest/")) + if result: + msgs.info("Opened PypeIT docs.") + else: + msgs.warn("Failed to open PypeIt docs at 'https://pypeit.readthedocs.io/en/latest/'") + def _create_button_box(self): """Create the box with action buttons. @@ -1587,6 +1596,12 @@ def _create_button_box(self): button_layout.addStretch() + button = QPushButton(text = 'Help') + button.setToolTip("Opens PypeIt online documentation.") + button.clicked.connect(self._helpButton) + button_layout.addWidget(button) + self.helpButton = button + button = QPushButton(text = 'View log') button.setToolTip("Opens a window containing the log.") button.clicked.connect(self._showLog) From 816cdf1905d698dbd7f1da72b0db2f255758ccf6 Mon Sep 17 00:00:00 2001 From: Dusty Reichwein Date: Fri, 18 Oct 2024 17:50:21 -0700 Subject: [PATCH 06/12] Bug fixes relating to how the application was initialized --- pypeit/scripts/setup.py | 5 +- pypeit/setup_gui/controller.py | 102 ++++++++++++++++++++------------- 2 files changed, 63 insertions(+), 44 deletions(-) diff --git a/pypeit/scripts/setup.py b/pypeit/scripts/setup.py index 8f2475427f..810b442093 100644 --- a/pypeit/scripts/setup.py +++ b/pypeit/scripts/setup.py @@ -95,9 +95,8 @@ def main(args): if args.gui: # Start the GUI - from pypeit.setup_gui.controller import SetupGUIController - gui = SetupGUIController(args.verbosity, args.spectrograph, args.root, args.extension) - gui.start() + from pypeit.setup_gui.controller import start_gui + start_gui(args) else: msgs.set_logfile_and_verbosity("setup", args.verbosity) diff --git a/pypeit/setup_gui/controller.py b/pypeit/setup_gui/controller.py index 10c33f276d..015da31b7d 100644 --- a/pypeit/setup_gui/controller.py +++ b/pypeit/setup_gui/controller.py @@ -58,11 +58,12 @@ class OperationThread(QThread): """Signal send the operation has completed.""" - def __init__(self): + def __init__(self,main_controller): super().__init__() self._operation = None self._max_progress = None self._mutex = QMutex() + self._main_window = main_controller.main_window def run(self): """Runs an operation in a background thread.""" @@ -98,11 +99,11 @@ def _op_progress(self, max_progress, progress_message=None): if create_progress: # If we've just initialized the max progress, create the progress dialog - SetupGUIController.main_window.create_progress_dialog(self._operation.name, max_progress, self._cancel_op) + self._main_window.create_progress_dialog(self._operation.name, max_progress, self._cancel_op) # Ignore the progress if there's no max progress yet if mp is not None: - SetupGUIController.main_window.show_operation_progress(increase=1, message = progress_message) + self._main_window.show_operation_progress(increase=1, message = progress_message) def _op_complete(self, canceled, exc_info): """Signal handler that is notified when a background operation completes. @@ -122,7 +123,7 @@ def _op_complete(self, canceled, exc_info): if operation is not None: operation.progressMade.disconnect(self._op_progress) - SetupGUIController.main_window.operation_complete() + self._main_window.operation_complete() operation.postRun(canceled, exc_info) @@ -144,18 +145,22 @@ class MetadataOperation(QObject): """Base class for Metadata operations that take long enough that they should take place in a background thread. Args: - model (PypeItSetupGUIModel): The PypeIt Setup GUI's model object. + model (PypeItSetupGUIModel): + The PypeIt Setup GUI's model object. + main_controller (SetupGUIController): + The PypeIt Setup GUI's controller object. """ progressMade = Signal(int, str) """Signal emitted emit when progress has been made. This will be reflected in the view's progress dialog.""" - def __init__(self, name, model): + def __init__(self, name, model, main_controller): super().__init__() self._model=model self.name = name self._max_progress = None + self._main_window = main_controller.main_window def preRun(self): """ @@ -196,7 +201,7 @@ def postRun(self, canceled, exc_info): if exc_info[0] is not None: traceback_string = "".join(traceback.format_exception(*exc_info)) msgs.warn(f"Failed to {self.name.lower()}:\n" + traceback_string) - display_error(SetupGUIController.main_window, f"Failed to {self.name.lower()} {exc_info[0]}: {exc_info[1]}") + display_error(self._main_window.main_window, f"Failed to {self.name.lower()} {exc_info[0]}: {exc_info[1]}") self._model.reset() elif canceled: self._model.reset() @@ -210,9 +215,11 @@ class SetupOperation(MetadataOperation): Args: model (PypeItSetupGUIModel): The PypeIt Setup GUI's model object. + main_controller (SetupGUIController): + The PypeIt Setup GUI's controller object. """ - def __init__(self, model): - super().__init__("Run Setup", model) + def __init__(self, model, main_controller): + super().__init__("Run Setup", model, main_controller) def run(self): """ @@ -229,10 +236,12 @@ class OpenFileOperation(MetadataOperation): The PypeIt Setup GUI's model object. file (): The file to open. + main_controller (SetupGUIController): + The PypeIt Setup GUI's controller object. """ - def __init__(self, model, file): - super().__init__("Open File", model) + def __init__(self, model, file, main_controller): + super().__init__("Open File", model, main_controller) self._file = file def run(self): @@ -325,7 +334,7 @@ def updateEnabledStatus(self): there are rows to paste in the clipboard.""" if self._controller._is_pypeit_file: - if SetupGUIController.model.clipboard.rowCount() > 0: + if self._controller.clipboard.rowCount() > 0: self.setEnabled(True) else: self.setEnabled(False) @@ -344,9 +353,10 @@ class PypeItMetadataController(QObject): True if the model is for a PypeItFileModel (that is writeable model), False if it is from a PypeItObsLog model (read only) """ - def __init__(self, model, is_pypeit_file): + def __init__(self, model, is_pypeit_file, main_controller): super().__init__() self._model = model + self._main_controller = main_controller self._view = None self._is_pypeit_file = is_pypeit_file self._windows = {} @@ -361,9 +371,13 @@ def __init__(self, model, is_pypeit_file): MetadataWriteAction(self, "Comment Out", self.comment_out_metadata_rows), MetadataWriteAction(self, "Uncomment", self.uncomment_metadata_rows), MetadataWriteAction(self, "Delete", self.delete_metadata_rows, shortcut=QKeySequence.StandardKey.Delete) ] - SetupGUIController.model.clipboard.modelReset.connect(self.updatedEnabledActions) + self._main_controller.model.clipboard.modelReset.connect(self.updatedEnabledActions) self.updatedEnabledActions() + @property + def clipboard(self): + return self._main_controller.model.clipboard + def getActions(self, parent): """Returns the actions that this controller supports. @@ -396,7 +410,7 @@ def view_file(self): try: display.connect_to_ginga(raise_err=True, allow_new=True) except Exception as e: - display_error(SetupGUIController.main_window, f"Could not start ginga to view FITS files: {e}") + display_error(self._main_controller.main_window, f"Could not start ginga to view FITS files: {e}") msgs.warn(f"Failed to connect to ginga:\n" + traceback.format_exc()) @@ -406,19 +420,19 @@ def view_file(self): # Make sure to strip comments off commented out files file = Path(metadata_row['directory'], metadata_row['filename'].lstrip('# ')) if not file.exists(): - display_error(SetupGUIController.main_window, f"Could not find {file.name} in {file.parent}.") + display_error(self._main_controller.main_window, f"Could not find {file.name} in {file.parent}.") return try: img = self._model.spectrograph.get_rawimage(str(file), 1)[1] except Exception as e: - display_error(SetupGUIController.main_window, f"Failed to read image {file.name}: {e}") + display_error(self._main_controller.main_window, f"Failed to read image {file.name}: {e}") msgs.warn(f"Failed get raw image:\n" + traceback.format_exc()) try: display.show_image(img, chname = f"{file.name}") except Exception as e: - display_error(SetupGUIController.main_window, f"Failed to send image {file.name} to ginga: {e}") + display_error(self._main_controller.main_window, f"Failed to send image {file.name} to ginga: {e}") msgs.warn(f"Failed send image to ginga:\n" + traceback.format_exc()) def view_header(self): @@ -438,7 +452,7 @@ def view_header(self): print(f"\n\n# HDU {i} Header from {file.name}\n",file=header_string_buffer) hdu.header.totextfile(header_string_buffer) except Exception as e: - display_error(SetupGUIController.main_window, f"Failed to read header from file {file.name} in {file.parent}: {e}") + display_error(self._main_controller.main_window, f"Failed to read header from file {file.name} in {file.parent}: {e}") msgs.warn(f"Failed to read header from {file}:\n" + traceback.format_exc()) return header_string_buffer.seek(0) @@ -465,7 +479,7 @@ def copy_metadata_rows(self) -> bool: msgs.info(f"Copying {len(row_indices)} rows to the clipboard.") if len(row_indices) > 0: row_model = self._model.createCopyForRows(row_indices) - SetupGUIController.model.clipboard = row_model + self._main_controller.model.clipboard = row_model return True return False @@ -483,7 +497,7 @@ def cut_metadata_rows(self) -> bool: def paste_metadata_rows(self): """Insert rows from the clipboard into the PypeItMetadataModel""" - clipboard = SetupGUIController.model.clipboard + clipboard = self._main_controller.model.clipboard if clipboard.rowCount() > 0: try: msgs.info(f"Pasting {clipboard.rowCount()} rows") @@ -491,7 +505,7 @@ def paste_metadata_rows(self): except Exception as e: traceback_string = "".join(traceback.format_exc()) msgs.warn(f"Failed to paste metadata rows:\n" + traceback_string) - display_error(SetupGUIController.main_window, f"Could not paste rows to this PypeIt file: {e}") + display_error(self._main_controller.main_window, f"Could not paste rows to this PypeIt file: {e}") def comment_out_metadata_rows(self): @@ -536,19 +550,19 @@ class PypeItObsLogController(QObject): Args: main_window (:obj:`UserPromptDelegate`): A view object that can prompt the user. model (:obj:`PypeItObsLogModel`): The model for the obs log. - operation_thread (:obj:`pypeit.setup_gui.controller.SetupGUIController`): The main Setup GUI controller. + main_controller (:obj:`pypeit.setup_gui.controller.SetupGUIController`): The main Setup GUI controller. """ - def __init__(self, model, setup_gui_controller): + def __init__(self, model, main_controller): super().__init__() self._model = model - self.setup_gui_controller = setup_gui_controller + self._main_controller = main_controller def setModel(self, model): self._model = model def getMetadataController(self, model): - return PypeItMetadataController(model, is_pypeit_file=False) + return PypeItMetadataController(model, is_pypeit_file=False, main_controller=self._main_controller) def setSpectrograph(self, spectrograph_name): @@ -556,7 +570,7 @@ def setSpectrograph(self, spectrograph_name): if self._model.state != ModelState.NEW: # Re-run setup with the new spectrograph - self.setup_gui_controller.run_setup() + self._main_controller.run_setup() else: self._model.set_spectrograph(spectrograph_name) @@ -578,16 +592,18 @@ class PypeItFileController(QObject): Args: main_window (:obj:`UserPromptDelegate`): A view object that can prompt the user. model (:obj:`PypeItFileModel`): The model for the obs log. + main_controller (:obj:`pypeit.setup_gui.controller.SetupGUIController`): The main Setup GUI controller. """ - def __init__(self, model): + def __init__(self, model, main_controller): self._model = model + self._main_controller = main_controller def setModel(self, model): self._model = model def getMetadataController(self, model): - return PypeItMetadataController(model, is_pypeit_file=True) + return PypeItMetadataController(model, is_pypeit_file=True, main_controller=self._main_controller) class SetupGUIController(QObject): @@ -599,19 +615,15 @@ class SetupGUIController(QObject): """ - main_window = None - model = PypeItSetupGUIModel() - def __init__(self, verbosity : int, spectrograph : str|None=None, root : list[str]|str|None=None, extension : str|None=".fits"): + def __init__(self, app : QApplication, verbosity : int, spectrograph : str|None=None, root : list[str]|str|None=None, extension : str|None=".fits"): super().__init__() - # Note QT expects the program name as arg 0 - self.app = QApplication(sys.argv) - + self.app = app QCoreApplication.setOrganizationName("PypeIt") QCoreApplication.setApplicationName("SetupGUI") QCoreApplication.setOrganizationDomain("pypeit.readthedocs.io") - + self.model = PypeItSetupGUIModel() self.model.setup_logging(verbosity=verbosity) if spectrograph is not None: @@ -636,8 +648,9 @@ def __init__(self, verbosity : int, spectrograph : str|None=None, root : list[st msgs.info(f"Setting font to 12.") defaultFont.setPointSize(12) self.app.setFont(defaultFont) - SetupGUIController.main_window = SetupGUIMainWindow(self.model, self) - self.operation_thread = OperationThread() + + self.main_window = SetupGUIMainWindow(self.model, self) + self.operation_thread = OperationThread(self) def getObsLogController(self, model): @@ -655,7 +668,7 @@ def getPypeItFileController(self, model): Args: model (:obj:`PypeItFileModel`): The model for the obs log. """ - return PypeItFileController(model) + return PypeItFileController(model, self) def start(self): """ @@ -806,7 +819,7 @@ def run_setup(self): elif response == DialogResponses.CANCEL: return - self.operation_thread.startOperation(SetupOperation(self.model)) + self.operation_thread.startOperation(SetupOperation(self.model, self)) def createNewPypeItFile(self): # First figure out the name @@ -848,4 +861,11 @@ def open_pypeit_file(self): open_dialog = FileDialog.create_open_file_dialog(self.main_window, "Select PypeIt File", file_type=FileType("PypeIt input files",".pypeit")) result = open_dialog.show() if result != DialogResponses.CANCEL: - self.operation_thread.startOperation(OpenFileOperation(self.model, open_dialog.selected_path)) + self.operation_thread.startOperation(OpenFileOperation(self.model, open_dialog.selected_path, self)) + +def start_gui(args): + # Note QT expects the program name as arg 0 + app = QApplication(sys.argv) + + gui = SetupGUIController(app, args.verbosity, args.spectrograph, args.root, args.extension) + gui.start() From 487aa17f69e222365a25792f7ec8bc65db7b023d Mon Sep 17 00:00:00 2001 From: Dusty Reichwein Date: Tue, 29 Oct 2024 19:44:29 -0700 Subject: [PATCH 07/12] Add support for viewing specific detectors/mosaics of files --- pypeit/setup_gui/controller.py | 38 +++++++++++++++++++++++++++++----- pypeit/setup_gui/view.py | 19 ++++++++++++++--- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/pypeit/setup_gui/controller.py b/pypeit/setup_gui/controller.py index 015da31b7d..ccc6222b46 100644 --- a/pypeit/setup_gui/controller.py +++ b/pypeit/setup_gui/controller.py @@ -7,7 +7,6 @@ """ import traceback import sys -import threading from datetime import datetime import re import io @@ -363,6 +362,7 @@ def __init__(self, model, is_pypeit_file, main_controller): self.next_window_id = 1 # Build actions + # Dynamic view file actions are build later self._action_list = [MetadataReadOnlyAction(self, "View File", self.view_file), MetadataReadOnlyAction(self, "View Header", self.view_header), MetadataReadOnlyAction(self, "Copy", self.copy_metadata_rows, shortcut=QKeySequence.StandardKey.Copy), @@ -398,11 +398,39 @@ def setView(self, view): def updatedEnabledActions(self): """Updates which actions are enabled/disabled.""" + + spectrograph = self._model.spectrograph + + if spectrograph is not None: + num_mosaics = len(self._model.spectrograph.allowed_mosaics) + num_detector = self._model.spectrograph.ndet + view_file_actions = ["View File"] + \ + [MetadataReadOnlyAction(self, f"Detector {n+1}", partial(self.view_file, n+1,mosaic=False)) for n in range(num_detector)] + \ + [MetadataReadOnlyAction(self, f"Mosaic {self._model.spectrograph.allowed_mosaics[n]}", partial(self.view_file, n+1,mosaic=True)) for n in range(num_mosaics)] + else: + view_file_actions = MetadataReadOnlyAction(self, "View File", self.view_file) + + self._action_list[0] = view_file_actions + for action in self._action_list: - action.updateEnabledStatus() + if isinstance(action, list): + for subaction in action[1:]: + subaction.updateEnabledStatus() + else: + action.updateEnabledStatus() - def view_file(self): + def view_file(self, n=None, mosaic=False): """View the selected files in the metadata using Ginga.""" + if n == None: + # Default to detector 1 + n = 1 + + if mosaic: + n = self._model.spectrograph.allowed_mosaics[n-1] + det_name = f"MSC {n}" + else: + det_name = f"DET {n}" + row_indices = self._view.selectedRows() if len(row_indices) > 0: @@ -424,13 +452,13 @@ def view_file(self): return try: - img = self._model.spectrograph.get_rawimage(str(file), 1)[1] + img = self._model.spectrograph.get_rawimage(str(file), n)[1] except Exception as e: display_error(self._main_controller.main_window, f"Failed to read image {file.name}: {e}") msgs.warn(f"Failed get raw image:\n" + traceback.format_exc()) try: - display.show_image(img, chname = f"{file.name}") + display.show_image(img, chname = f"{file.name} {det_name}") except Exception as e: display_error(self._main_controller.main_window, f"Failed to send image {file.name} to ginga: {e}") msgs.warn(f"Failed send image to ginga:\n" + traceback.format_exc()) diff --git a/pypeit/setup_gui/view.py b/pypeit/setup_gui/view.py index 60fa066388..5a086f9318 100644 --- a/pypeit/setup_gui/view.py +++ b/pypeit/setup_gui/view.py @@ -7,7 +7,7 @@ from pathlib import Path from qtpy.QtWidgets import QGroupBox, QHBoxLayout, QVBoxLayout, QComboBox, QToolButton, QFileDialog, QWidget, QGridLayout, QFormLayout -from qtpy.QtWidgets import QMessageBox, QTabWidget, QTreeView, QLayout, QLabel, QScrollArea, QListView, QTableView, QPushButton, QStyleOptionButton, QProgressDialog, QDialog, QHeaderView, QSizePolicy, QCheckBox, QDialog +from qtpy.QtWidgets import QMenu, QTabWidget, QTreeView, QLayout, QLabel, QScrollArea, QListView, QTableView, QPushButton, QStyleOptionButton, QProgressDialog, QDialog, QHeaderView, QSizePolicy, QCheckBox, QDialog from qtpy.QtWidgets import QAction, QAbstractItemView, QStyledItemDelegate, QButtonGroup, QStyle, QTabBar,QAbstractItemDelegate, QSplitter from qtpy.QtGui import QIcon,QDesktopServices, QMouseEvent, QKeySequence, QPalette, QColor, QValidator, QFont, QFontDatabase, QFontMetrics, QTextCharFormat, QTextCursor from qtpy.QtCore import Qt, QUrl, QObject, QEvent, QSize, Signal,QSettings, QStringListModel, QAbstractItemModel, QModelIndex, QMargins, QSortFilterProxyModel, QRect @@ -559,8 +559,7 @@ def __init__(self, parent, model, controller): if min_height > self.minimumHeight(): self.setMinimumHeight(min_height) - self.addActions(controller.getActions(self)) - self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu) self.setMouseTracking(True) @@ -643,6 +642,20 @@ def showEvent(self, event : QEvent): self.resizeRowsToContents() self._shownOnce=True + def contextMenuEvent(self, event): + menu = QMenu() + actions = self._controller.getActions(self) + for action in actions: + if not isinstance(action,list): + menu.addAction(action) + else: + submenu = QMenu() + submenu.setTitle(action[0]) + for subaction in action[1:]: + submenu.addAction(subaction) + menu.addMenu(submenu) + menu.exec_(event.globalPos()) + def selectionChanged(self, selected, deselected): """Event handler called by Qt when a selection change. Overriden from QTableView. From c9ec61b02c909db81855209e0b01a003c998d894 Mon Sep 17 00:00:00 2001 From: Dusty Reichwein Date: Mon, 4 Nov 2024 13:20:03 -0800 Subject: [PATCH 08/12] Remove displaying Mosaics as that requires processing out of scope for the setup GUI --- pypeit/setup_gui/controller.py | 17 +++++++---------- pypeit/setup_gui/view.py | 25 +++++++------------------ 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/pypeit/setup_gui/controller.py b/pypeit/setup_gui/controller.py index ccc6222b46..be3adba0d0 100644 --- a/pypeit/setup_gui/controller.py +++ b/pypeit/setup_gui/controller.py @@ -362,7 +362,7 @@ def __init__(self, model, is_pypeit_file, main_controller): self.next_window_id = 1 # Build actions - # Dynamic view file actions are build later + # Dynamic View File actions are build later in updateEnabledActions self._action_list = [MetadataReadOnlyAction(self, "View File", self.view_file), MetadataReadOnlyAction(self, "View Header", self.view_header), MetadataReadOnlyAction(self, "Copy", self.copy_metadata_rows, shortcut=QKeySequence.StandardKey.Copy), @@ -401,12 +401,12 @@ def updatedEnabledActions(self): spectrograph = self._model.spectrograph + # The actions under "View File" depend on the current spectrgraph, and so are dynamically setup here as a nested submenu if spectrograph is not None: - num_mosaics = len(self._model.spectrograph.allowed_mosaics) num_detector = self._model.spectrograph.ndet view_file_actions = ["View File"] + \ - [MetadataReadOnlyAction(self, f"Detector {n+1}", partial(self.view_file, n+1,mosaic=False)) for n in range(num_detector)] + \ - [MetadataReadOnlyAction(self, f"Mosaic {self._model.spectrograph.allowed_mosaics[n]}", partial(self.view_file, n+1,mosaic=True)) for n in range(num_mosaics)] + [MetadataReadOnlyAction(self, f"Detector {n+1}", partial(self.view_file, n+1)) for n in range(num_detector)] + else: view_file_actions = MetadataReadOnlyAction(self, "View File", self.view_file) @@ -414,22 +414,19 @@ def updatedEnabledActions(self): for action in self._action_list: if isinstance(action, list): + # The nested "View File" sub menu for subaction in action[1:]: subaction.updateEnabledStatus() else: action.updateEnabledStatus() - def view_file(self, n=None, mosaic=False): + def view_file(self, n=None): """View the selected files in the metadata using Ginga.""" if n == None: # Default to detector 1 n = 1 - if mosaic: - n = self._model.spectrograph.allowed_mosaics[n-1] - det_name = f"MSC {n}" - else: - det_name = f"DET {n}" + det_name = f"DET {n}" row_indices = self._view.selectedRows() if len(row_indices) > 0: diff --git a/pypeit/setup_gui/view.py b/pypeit/setup_gui/view.py index 5a086f9318..0168810596 100644 --- a/pypeit/setup_gui/view.py +++ b/pypeit/setup_gui/view.py @@ -643,9 +643,15 @@ def showEvent(self, event : QEvent): self._shownOnce=True def contextMenuEvent(self, event): + """Build and display a context menu for file metadata""" menu = QMenu() + + # Build a menu from the controller's actions + # A submenu is represetned by a nested list, + # currently we only supported one level of nesting. actions = self._controller.getActions(self) for action in actions: + # if not isinstance(action,list): menu.addAction(action) else: @@ -654,6 +660,7 @@ def contextMenuEvent(self, event): for subaction in action[1:]: submenu.addAction(subaction) menu.addMenu(submenu) + # Display the menu menu.exec_(event.globalPos()) def selectionChanged(self, selected, deselected): @@ -999,24 +1006,6 @@ def __init__(self, model, controller): # Monitor the model for updates self.model.stateChanged.connect(self.update_from_model) - def splitterMoved(self, pos, index): - msgs.info(f"splitter moved pos {pos} index {index}") - msgs.info(f"params size/sizeHint/minSizeHint: {self.params_group.size()} / {self.params_group.sizeHint()} / {self.params_group.minimumSizeHint()}") - msgs.info(f"setup size/sizeHint/minSizeHint: {self.config_panel.size()} / {self.config_panel.sizeHint()} / {self.config_panel.minimumSizeHint()}") - msgs.info(f"paths size/sizeHint/minSizeHint: {self.paths_group.size()} / {self.paths_group.sizeHint()} / {self.paths_group.minimumSizeHint()}") - msgs.info(f"metadata size/sizeHint/minSizeHint: {self.file_group.size()} / {self.file_group.sizeHint()} / {self.file_group.minimumSizeHint()}") - msgs.info(f"Splitter sizes: {self.splitter.sizes()}") - #sizes = self.splitter.sizes() - #msgs.info(f"Sizes: {self.splitter.sizes()}") - #pg_index = self.splitter.indexOf(self.params_group) - #if self.params_group.size().height == 0: - # fm = self.params_group.fontMetrics() - # self.params_group.setMinimumHeight(fm.height()) - # self.splitter.setCollapsible(pg_index,False) - # sizes[pg_index] = fm.height() - # self.splitter.setSizes(sizes) - #elif self.splitter.isCollapsible(pg_index) is False and self.params_group.size().height > 0: - # self.splitter.setCollapsible(pg_index,True) def update_from_model(self): """ Signal handler that updates view when the underlying model changes. From ffd09e07e8b203678adf8a784fc304ea6dfbb176 Mon Sep 17 00:00:00 2001 From: Dusty Reichwein Date: Tue, 3 Dec 2024 18:21:41 -0800 Subject: [PATCH 09/12] Fixes for MacOS: Crashing when opening file, sizing of group boxes, monospace fonts, and application icon --- pypeit/setup_gui/controller.py | 25 +++++++------ pypeit/setup_gui/model.py | 5 +-- pypeit/setup_gui/text_viewer.py | 8 +++-- pypeit/setup_gui/view.py | 64 ++++++++++++++++++++++++--------- 4 files changed, 71 insertions(+), 31 deletions(-) diff --git a/pypeit/setup_gui/controller.py b/pypeit/setup_gui/controller.py index be3adba0d0..8ab6f94e34 100644 --- a/pypeit/setup_gui/controller.py +++ b/pypeit/setup_gui/controller.py @@ -14,6 +14,7 @@ from functools import partial from contextlib import contextmanager from qtpy.QtCore import QCoreApplication, Signal, QMutex, QTimer +from qtpy.QtGui import QIcon # TODO: datetime.UTC is not defined in python 3.10. Remove this when we decide # to no longer support it. @@ -63,12 +64,15 @@ def __init__(self,main_controller): self._max_progress = None self._mutex = QMutex() self._main_window = main_controller.main_window + self.completed.connect(self._op_complete, type=Qt.QueuedConnection) + def run(self): """Runs an operation in a background thread.""" canceled = False exc_info = (None, None, None) try: + msgs.info("Running operation") self._operation.run() except OpCanceledError: canceled=True @@ -133,10 +137,10 @@ def startOperation(self, operation): Args: operation (MetadataOperation): The MetadataOperation to start in the background thread. """ + msgs.info("Starting operation") self._operation = operation if operation.preRun(): operation.progressMade.connect(self._op_progress, type=Qt.QueuedConnection) - self.completed.connect(self._op_complete, type=Qt.QueuedConnection) self.start() class MetadataOperation(QObject): @@ -200,7 +204,7 @@ def postRun(self, canceled, exc_info): if exc_info[0] is not None: traceback_string = "".join(traceback.format_exception(*exc_info)) msgs.warn(f"Failed to {self.name.lower()}:\n" + traceback_string) - display_error(self._main_window.main_window, f"Failed to {self.name.lower()} {exc_info[0]}: {exc_info[1]}") + display_error(self._main_window, f"Failed to {self.name.lower()} {exc_info[0]}: {exc_info[1]}") self._model.reset() elif canceled: self._model.reset() @@ -593,13 +597,6 @@ def getMetadataController(self, model): def setSpectrograph(self, spectrograph_name): self._model.set_spectrograph(spectrograph_name) - if self._model.state != ModelState.NEW: - # Re-run setup with the new spectrograph - self._main_controller.run_setup() - - else: - self._model.set_spectrograph(spectrograph_name) - def removePaths(self, rows): # Remove paths in reverse order, so that indexes don't change when a row is removed for row in sorted(rows, reverse=True): @@ -843,7 +840,7 @@ def run_setup(self): self.save_all() elif response == DialogResponses.CANCEL: return - + msgs.info("run_setup starting operation") self.operation_thread.startOperation(SetupOperation(self.model, self)) def createNewPypeItFile(self): @@ -886,11 +883,19 @@ def open_pypeit_file(self): open_dialog = FileDialog.create_open_file_dialog(self.main_window, "Select PypeIt File", file_type=FileType("PypeIt input files",".pypeit")) result = open_dialog.show() if result != DialogResponses.CANCEL: + msgs.info("open_pypeit_file starting operation") self.operation_thread.startOperation(OpenFileOperation(self.model, open_dialog.selected_path, self)) def start_gui(args): # Note QT expects the program name as arg 0 app = QApplication(sys.argv) + # Setup application/window icon TODO this doesn't work in windows. + iconPath = Path(__file__).parent / "images/window_icon.png" + if not iconPath.exists(): + msgs.info("Icon path does not exist") + else: + app.setWindowIcon(QIcon(str(iconPath))) + gui = SetupGUIController(app, args.verbosity, args.spectrograph, args.root, args.extension) gui.start() diff --git a/pypeit/setup_gui/model.py b/pypeit/setup_gui/model.py index 5e4e3dff89..86d46546ae 100644 --- a/pypeit/setup_gui/model.py +++ b/pypeit/setup_gui/model.py @@ -1391,7 +1391,7 @@ def open_pypeit_file(self, pypeit_file): pf_model.stateChanged.connect(self.stateChanged) self.pypeit_files[setup_name] = pf_model - + msgs.info("Adding empty file model in open_pypeit_file") self.filesAdded.emit([pf_model]) self.stateChanged.emit() @@ -1416,7 +1416,7 @@ def createEmptyPypeItFile(self, new_name): pf_model.stateChanged.connect(self.stateChanged) self.pypeit_files[new_name] = pf_model - + msgs.info("Adding emtpy pypeit file in createEmptyPypeItFile") self.filesAdded.emit([pf_model]) self.stateChanged.emit() return pf_model @@ -1460,5 +1460,6 @@ def createFilesForConfigs(self, configs=None, state=ModelState.CHANGED): msgs.info(f"Current files: {self.pypeit_files}") if len(config_names) > 0: + msgs.info("Adding pypeit files in createFilesForConfigs") self.filesAdded.emit(list(self.pypeit_files.values())) diff --git a/pypeit/setup_gui/text_viewer.py b/pypeit/setup_gui/text_viewer.py index db00153125..82848b6a1a 100644 --- a/pypeit/setup_gui/text_viewer.py +++ b/pypeit/setup_gui/text_viewer.py @@ -10,7 +10,7 @@ from qtpy.QtWidgets import QHBoxLayout, QVBoxLayout, QFileDialog, QWidget, QPlainTextEdit, QPushButton -from qtpy.QtGui import QIcon, QFont,QTextCursor +from qtpy.QtGui import QIcon, QFont,QTextCursor,QFontDatabase from qtpy.QtCore import Qt, Signal, QSettings, QEvent from pypeit import msgs @@ -50,9 +50,11 @@ def __init__(self, title : str, width : int, height : int, text_stream : io.Text # Get a fixed width font fixed_font = QFont() - fixed_font.setFamilies(["Monospace", "Courier"]) - fixed_font.setFixedPitch(True) + fixed_font.setFamilies(["Monospace","Menlo","Courier New"]) fixed_font.setPointSize(12) +# msgs.info("Available font families:") +# for family in QFontDatabase.families(): +# msgs.info(family) # Create the log viewer widget self.textViewer=QPlainTextEdit(parent=self) diff --git a/pypeit/setup_gui/view.py b/pypeit/setup_gui/view.py index 0168810596..d83907e8dc 100644 --- a/pypeit/setup_gui/view.py +++ b/pypeit/setup_gui/view.py @@ -9,7 +9,7 @@ from qtpy.QtWidgets import QGroupBox, QHBoxLayout, QVBoxLayout, QComboBox, QToolButton, QFileDialog, QWidget, QGridLayout, QFormLayout from qtpy.QtWidgets import QMenu, QTabWidget, QTreeView, QLayout, QLabel, QScrollArea, QListView, QTableView, QPushButton, QStyleOptionButton, QProgressDialog, QDialog, QHeaderView, QSizePolicy, QCheckBox, QDialog from qtpy.QtWidgets import QAction, QAbstractItemView, QStyledItemDelegate, QButtonGroup, QStyle, QTabBar,QAbstractItemDelegate, QSplitter -from qtpy.QtGui import QIcon,QDesktopServices, QMouseEvent, QKeySequence, QPalette, QColor, QValidator, QFont, QFontDatabase, QFontMetrics, QTextCharFormat, QTextCursor +from qtpy.QtGui import QDesktopServices, QMouseEvent, QKeySequence, QPalette, QColor, QValidator, QFont, QFontDatabase, QFontMetrics, QTextCharFormat, QTextCursor from qtpy.QtCore import Qt, QUrl, QObject, QEvent, QSize, Signal,QSettings, QStringListModel, QAbstractItemModel, QModelIndex, QMargins, QSortFilterProxyModel, QRect from pypeit.spectrographs import available_spectrographs @@ -625,6 +625,11 @@ def _handleModelReset(self): """ Fix column and row sizes after the model is reset. """ + colCount = self.model().columnCount() + msgs.info(f"# Cols: {colCount}") + colSizeHints = [self.sizeHintForColumn(i) for i in range(colCount)] + msgs.info(f"Col size hints: {colSizeHints}") + self.resizeColumnsToContents() self.resizeRowsToContents() @@ -750,14 +755,17 @@ def __init__(self, spec_name, config, lines_to_display, parent=None): self._form_widget.setMinimumWidth(self._getMinWidth()) + layout.addWidget(self._scroll_area) + + # Set margins within the group box + group_box_margin = int(fm.height()/2) + layout.setContentsMargins(group_box_margin, group_box_margin, group_box_margin, group_box_margin) + # Figure out the correct height for this panel, so that only the spectrograph and self.number_of_lines # config keys are visible - - # Find the minimum height of the form widget needed to hold the total number of config lines msgs.info(f"font height: {fm.height()} vertical spacing {self._form_widget_layout.verticalSpacing()}") - self.setMaximumHeight(self.computeHeight(len(self._config_labels))) + self.setMaximumHeight(self.computeHeight(max(self.lines_to_display, len(self._config_labels)))) - layout.addWidget(self._scroll_area) def computeHeight(self, lines_to_display:int) ->int: """Compute the height needed to display a given number of lines @@ -768,19 +776,33 @@ def computeHeight(self, lines_to_display:int) ->int: The vertical size in pixels needed to display the given number of configuration lines """ fm = self.fontMetrics() - min_fw_height = self._form_widget_layout.verticalSpacing()*(lines_to_display-1) + fm.height()*lines_to_display + verticalSpacing = self._form_widget_layout.verticalSpacing() + if verticalSpacing == -1: + verticalSpacing = fm.leading() + self._form_widget_layout.setVerticalSpacing(fm.leading()) + msgs.info(f"Set vertical spacing to {verticalSpacing}") + min_fw_height = (verticalSpacing)*(lines_to_display-1) + fm.height()*lines_to_display # The height of this panel is that height plus the margins + the group box title scroll_area_margins = self._scroll_area.contentsMargins() group_box_margins = self.contentsMargins() form_widget_margins = self._form_widget.contentsMargins() - - return (min_fw_height + - fm.height() + # Group Box Title + layout_margins = self.layout().contentsMargins() + + msgs.info(f"verticalSpacing: {self._form_widget_layout.verticalSpacing()}") + msgs.info(f"fontMetrics height/leading: {fm.height()}/{fm.leading()}") + msgs.info(f"group_box_margins (t/b) ({group_box_margins.top()}/{group_box_margins.bottom()})") + msgs.info(f"scroll_area_margins (t/b) ({scroll_area_margins.top()}/{scroll_area_margins.bottom()})") + msgs.info(f"layout_margins (t/b) ({layout_margins.top()}/{layout_margins.bottom()})") + msgs.info(f"form_widget_margins (t/b) ({form_widget_margins.top()}/{form_widget_margins.bottom()})") + computedHeight = (min_fw_height + + # fm.height() + # Group Box Title group_box_margins.top() + group_box_margins.bottom() + scroll_area_margins.top() + scroll_area_margins.bottom() + + layout_margins.top() + layout_margins.bottom() + form_widget_margins.top() + form_widget_margins.bottom()) - + msgs.info(f"computedHeight: {computedHeight}") + return computedHeight def setNewValues(self, config_dict: dict) -> None: """Update the panel to display new configuration values. @@ -799,7 +821,9 @@ def setNewValues(self, config_dict: dict) -> None: # Reset the minimum width for the new values self._form_widget.setMinimumWidth(self._getMinWidth()) - + + # Reset the maximum height based on the new values. + self.setMaximumHeight(self.computeHeight(max(self.lines_to_display, len(self._config_labels)))) def _getMinWidth(self) -> int: """Calculate the minimum width needed to display the configuration values.""" @@ -912,8 +936,10 @@ def __init__(self, model, controller): self.params_tree.header().setSectionResizeMode(QHeaderView.ResizeToContents) self.params_tree.expandToDepth(1) params_group_layout.addWidget(self.params_tree) - params_group.setLayout(params_group_layout) fm = params_group.fontMetrics() + group_box_padding = int(fm.height()/2) + params_group_layout.setContentsMargins(group_box_padding,group_box_padding,group_box_padding,group_box_padding) + params_group.setLayout(params_group_layout) pg_cm = params_group.contentsMargins() pt_cm = self.params_tree.contentsMargins() @@ -946,6 +972,7 @@ def __init__(self, model, controller): paths_viewer = QListView(paths_group) paths_viewer.setModel(model.paths_model) paths_viewer.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + paths_group_layout.setContentsMargins(group_box_padding,group_box_padding,group_box_padding,group_box_padding) paths_group_layout.addWidget(paths_viewer) pg_fm = paths_group.fontMetrics() pv_fm = paths_viewer.fontMetrics() @@ -1006,6 +1033,9 @@ def __init__(self, model, controller): # Monitor the model for updates self.model.stateChanged.connect(self.update_from_model) + debugSizeStuff(self.config_panel,"Config Panel") + msgs.info(f"config panel flat: {self.config_panel.isFlat()}") + def update_from_model(self): """ Signal handler that updates view when the underlying model changes. @@ -1085,6 +1115,9 @@ def __init__(self, model, controller, parent=None): self.spectrograph.lineEdit().setPlaceholderText(self.tr("Select a spectrograph")) self.spectrograph.setInsertPolicy(QComboBox.NoInsert) self.spectrograph.setValidator(SpectrographValidator()) + fm = self.fontMetrics() + group_box_padding=int(fm.height()/2) + spectrograph_layout.setContentsMargins(group_box_padding,group_box_padding,group_box_padding,group_box_padding) spectrograph_layout.addWidget(self.spectrograph) spectrograph_layout.setAlignment(self.spectrograph, Qt.AlignTop) spectrograph_box.setLayout(spectrograph_layout) @@ -1105,15 +1138,16 @@ def __init__(self, model, controller, parent=None): self._paths_viewer = QListView(paths_group) self._paths_viewer.setModel(model.paths_model) paths_group_layout.addWidget(self._paths_viewer) - + paths_group_layout.setContentsMargins(group_box_padding,group_box_padding,group_box_padding,group_box_padding) + # The initial height of the first row in the splitter. The raw data paths will be larger # so we use its size for the row. We start with it displaying 2 paths initial_lines = 2 - fm = self.fontMetrics() viewer_margins = self._paths_viewer.contentsMargins() path_group_margins = paths_group.contentsMargins() spec_paths_init_height = (fm.lineSpacing() + # Group titles path_group_margins.top() + path_group_margins.bottom() + # Groupbox margins + group_box_padding + group_box_padding + # Group box layout margins self.paths_editor.sizeHint().height() + # Path editor paths_group_layout.spacing() + # Gap between editor and viewer viewer_margins.top() + viewer_margins.bottom() + # viewer margins @@ -1445,8 +1479,6 @@ def __init__(self, model, controller): self.model.filesAdded.connect(self.create_file_tabs) self.model.filesDeleted.connect(self.delete_tabs) - # Setup application/window icon TODO this doesn't work in windows. Mac??? - self.setWindowIcon(QIcon(str(Path(__file__).parent / "images/window_icon.png"))) self.setWindowTitle(self.tr("PypeIt Setup")) self.resize(1650,900) From b76d50bd209fb7cb85a386389b03eaa8ee87b869 Mon Sep 17 00:00:00 2001 From: Dusty Reichwein Date: Mon, 9 Dec 2024 13:02:43 -0800 Subject: [PATCH 10/12] Fix keyboard shortcuts for copying/cutting/pasting metadata --- pypeit/setup_gui/view.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pypeit/setup_gui/view.py b/pypeit/setup_gui/view.py index d83907e8dc..5dbfabd6c4 100644 --- a/pypeit/setup_gui/view.py +++ b/pypeit/setup_gui/view.py @@ -561,6 +561,16 @@ def __init__(self, parent, model, controller): self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu) + # Add any actions with shortcuts + for action in self._controller.getActions(self): + if isinstance(action, list): + # Iterate through sublist the first item is a text name of the submenu, followed by actions + for subaction in action[1:]: + if subaction.shortcut() is not None: + self.addAction(subaction) + elif action.shortcut() is not None: + self.addAction(action) + self.setMouseTracking(True) def mouseMoveEvent(self, event : QMouseEvent): From d99b715fae8ea55ea05bb10f69ffe09bc3b747f2 Mon Sep 17 00:00:00 2001 From: Dusty Reichwein Date: Mon, 9 Dec 2024 14:27:18 -0800 Subject: [PATCH 11/12] Update version notes for Setup GUI changes. --- doc/releases/1.17.1dev.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/releases/1.17.1dev.rst b/doc/releases/1.17.1dev.rst index 194bea27ad..68a0680070 100644 --- a/doc/releases/1.17.1dev.rst +++ b/doc/releases/1.17.1dev.rst @@ -17,6 +17,8 @@ Functionality/Performance Improvements and Additions - The WCS for datacubes now adopts the convention of North is up and East is left. In previous version of PypeIt, East was right. +- Setup GUI allows resizing panels when viewing pypeit files. +- Setup GUI allows selecting which detector to view when viewing raw data. Instrument-specific Updates --------------------------- @@ -25,7 +27,7 @@ Instrument-specific Updates Script Changes -------------- - +- `pypeit_setup_gui` script has been removed. The Setup GUI can be started with `pypeit_setup -G`. Datamodel Changes ----------------- @@ -43,4 +45,4 @@ Bug Fixes in both spatial dimensions equal to half the field of view. - Fix the code for the extraction of the 1D flat spectrum, so that the spectrum is extracted even when `pixelflat_model` does not exist. - +- Fix Setup GUI sizing issues on MacOS. From 5bc20a0bf496205617fc42cd807265b6f9fec924 Mon Sep 17 00:00:00 2001 From: Dusty Reichwein Date: Fri, 3 Jan 2025 11:50:23 -0800 Subject: [PATCH 12/12] Setup GUI Rework from PR review --- pypeit/scripts/setup.py | 1 - pypeit/setup_gui/controller.py | 11 +++++++---- pypeit/setup_gui/text_viewer.py | 3 --- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/pypeit/scripts/setup.py b/pypeit/scripts/setup.py index 810b442093..b9a17680a4 100644 --- a/pypeit/scripts/setup.py +++ b/pypeit/scripts/setup.py @@ -5,7 +5,6 @@ .. include:: ../include/links.rst """ import argparse -from datetime import datetime, timezone from IPython import embed from pypeit.scripts import scriptbase diff --git a/pypeit/setup_gui/controller.py b/pypeit/setup_gui/controller.py index 8ab6f94e34..904912f0b0 100644 --- a/pypeit/setup_gui/controller.py +++ b/pypeit/setup_gui/controller.py @@ -630,14 +630,17 @@ def getMetadataController(self, model): class SetupGUIController(QObject): """Controller for the PypeIt setup gui. It is responsible for initializing the GUI, - and performing actions requested by the user. + and performing actions requested by the user. On startup, it will process arguments in the same + way the non-GUI version of pypeit_setup does. Args: - logfile () + app: QApplication for this Qt app. + verbosity: Verbosity to use when logging. + spectrograph: Optional spectrograph to use on startup. + root: Optional root path for raw data on startup. + extension: Optional extension to look for when scanning raw data on startup. """ - - def __init__(self, app : QApplication, verbosity : int, spectrograph : str|None=None, root : list[str]|str|None=None, extension : str|None=".fits"): super().__init__() diff --git a/pypeit/setup_gui/text_viewer.py b/pypeit/setup_gui/text_viewer.py index 82848b6a1a..143b112490 100644 --- a/pypeit/setup_gui/text_viewer.py +++ b/pypeit/setup_gui/text_viewer.py @@ -52,9 +52,6 @@ def __init__(self, title : str, width : int, height : int, text_stream : io.Text fixed_font = QFont() fixed_font.setFamilies(["Monospace","Menlo","Courier New"]) fixed_font.setPointSize(12) -# msgs.info("Available font families:") -# for family in QFontDatabase.families(): -# msgs.info(family) # Create the log viewer widget self.textViewer=QPlainTextEdit(parent=self)