diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00b304f221..cba0313b1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,8 @@ env: # The test-installer job can run with the pyinstaller debug bundle INSTALLER_USE_DEBUG: false BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + # Python version to be used in the CI + CI_PYTHON_VERSION: 3.12 jobs: @@ -58,7 +60,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: 3.12 + python-version: ${{ env.CI_PYTHON_VERSION }} - uses: actions/checkout@v5 @@ -86,7 +88,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: 3.12 + python-version: ${{ env.CI_PYTHON_VERSION }} ### Check if this wheel is already cached @@ -440,6 +442,8 @@ jobs: strategy: fail-fast: false matrix: + os: [ windows-latest, macos-latest, ubuntu-22.04 ] + python-version: [ 3.12 ] include: ${{ fromJson(needs.matrix.outputs.installer-matrix-json) }} name: Test installer diff --git a/build_tools/requirements.txt b/build_tools/requirements.txt index 62f07930b5..867461f552 100644 --- a/build_tools/requirements.txt +++ b/build_tools/requirements.txt @@ -25,7 +25,7 @@ pyparsing PySide6 pytools qtconsole -sasdata +sasdata @ git+https://github.com/SasView/sasdata.git@refactor_24 sasmodels scipy siphash24 @@ -39,4 +39,4 @@ zope requests # Alphabetized list of OS-specific packages -pywin32; platform_system == "Windows" +pywin32; platform_system == "Windows" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4e69d98d88..4d4e6a269e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,10 +13,7 @@ requires = [ "periodictable", "pyopengl", "pyside6", - "qtconsole", - "scipy", - "superqt", - "sasdata", + "sasdata @ git+https://github.com/SasView/sasdata.git@refactor_24", "sasmodels", "twisted", "uncertainties", diff --git a/src/ascii_dialog/col_editor.py b/src/ascii_dialog/col_editor.py new file mode 100644 index 0000000000..6348bf805f --- /dev/null +++ b/src/ascii_dialog/col_editor.py @@ -0,0 +1,89 @@ +from typing import cast + +from PySide6.QtCore import Signal, Slot +from PySide6.QtWidgets import QHBoxLayout, QWidget + +from sasdata.ascii_reader_metadata import bidirectional_pairings +from sasdata.quantities.units import NamedUnit + +from ascii_dialog.column_unit import ColumnUnit + + +class ColEditor(QWidget): + """An editor widget which allows the user to specify the columns of the data + from a set of options based on which dataset type has been selected.""" + column_changed = Signal() + + def __init__(self, cols: int, options: list[str]): + super().__init__() + + self.cols = cols + self.options = options + self.layout = QHBoxLayout(self) + self.option_widgets: list[ColumnUnit] = [] + for _ in range(cols): + new_widget = ColumnUnit(self.options) + new_widget.column_changed.connect(self.onColumnUpdate) + self.layout.addWidget(new_widget) + self.option_widgets.append(new_widget) + + @Slot() + def onColumnUpdate(self): + column_changed = cast(ColumnUnit, self.sender()) + pairing = bidirectional_pairings.get(column_changed.currentColumn) + if pairing is not None: + for col_unit in self.option_widgets: + # Second condition is important because otherwise, this event will keep being called, and the GUI will + # go into an infinite loop. + if col_unit.currentColumn == pairing and col_unit.currentUnit != column_changed.currentUnit: + col_unit.currentUnit = column_changed.currentUnit + + def setCols(self, new_cols: int): + """Set the amount of columns for the user to edit.""" + + # Decides whether we need to extend the current set of combo boxes, or + # remove some. + if self.cols < new_cols: + for _ in range(new_cols - self.cols): + new_widget = ColumnUnit(self.options) + new_widget.column_changed.connect(self.onColumnUpdate) + self.layout.addWidget(new_widget) + self.option_widgets.append(new_widget) + + self.cols = new_cols + if self.cols > new_cols: + excess_cols = self.cols - new_cols + length = len(self.option_widgets) + excess_combo_boxes = self.option_widgets[length - excess_cols:length] + for box in excess_combo_boxes: + self.layout.removeWidget(box) + box.setParent(None) + self.option_widgets = self.option_widgets[0:length - excess_cols] + self.cols = new_cols + self.column_changed.emit() + + def setColOrder(self, cols: list[str]): + """Sets the series of currently selected columns to be cols, in that + order. If there are not enough column widgets include as many of the + columns in cols as possible. + + """ + try: + for i, col_name in enumerate(cols): + self.option_widgets[i].setCurrentColumn(col_name) + except IndexError: + pass # Can ignore because it means we've run out of widgets. + + def colNames(self) -> list[str]: + """Get a list of all of the currently selected columns.""" + return [widget.currentColumn for widget in self.option_widgets] + + @property + def columns(self) -> list[tuple[str, NamedUnit | None]]: + return [(widget.currentColumn, widget.currentUnit if widget.currentColumn != "" else None) for widget in self.option_widgets] + + def replaceOptions(self, new_options: list[str]) -> None: + """Replace options from which the user can choose for each column.""" + self.options = new_options + for widget in self.option_widgets: + widget.replaceOptions(new_options) diff --git a/src/ascii_dialog/column_unit.py b/src/ascii_dialog/column_unit.py new file mode 100644 index 0000000000..323d5ef0b5 --- /dev/null +++ b/src/ascii_dialog/column_unit.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 + +from PySide6.QtCore import Signal, Slot +from PySide6.QtGui import QRegularExpressionValidator +from PySide6.QtWidgets import QComboBox, QHBoxLayout, QSizePolicy, QWidget + +from sasdata.dataset_types import unit_kinds +from sasdata.default_units import defaults_or_fallback +from sasdata.quantities.units import NamedUnit + +from ascii_dialog.unit_selector import UnitSelector + + +def configure_size_policy(combo_box: QComboBox) -> None: + policy = combo_box.sizePolicy() + policy.setHorizontalPolicy(QSizePolicy.Policy.Ignored) + combo_box.setSizePolicy(policy) + +class ColumnUnit(QWidget): + """Widget with 2 combo boxes: one allowing the user to pick a column, and + another to specify the units for that column.""" + def __init__(self, options) -> None: + super().__init__() + self.col_widget = self.createColComboBox(options) + self.unit_widget = self.createUnitComboBox(self.col_widget.currentText()) + self.layout = QHBoxLayout(self) + self.layout.addWidget(self.col_widget) + self.layout.addWidget(self.unit_widget) + self.current_option: str + + column_changed = Signal() + + def createColComboBox(self, options: list[str]) -> QComboBox: + """Create the combo box for specifying the column based on the given + options.""" + new_combo_box = QComboBox() + configure_size_policy(new_combo_box) + for option in options: + new_combo_box.addItem(option) + new_combo_box.setEditable(True) + validator = QRegularExpressionValidator(r"[a-zA-Z0-9]+") + new_combo_box.setValidator(validator) + new_combo_box.currentTextChanged.connect(self.onOptionChange) + return new_combo_box + + def createUnitComboBox(self, selected_option: str) -> QComboBox: + """Create the combo box for specifying the unit for selected_option""" + new_combo_box = QComboBox() + configure_size_policy(new_combo_box) + new_combo_box.setEditable(True) + self.updateUnits(new_combo_box, selected_option) + new_combo_box.currentTextChanged.connect(self.onUnitChange) + return new_combo_box + + def updateUnits(self, unit_box: QComboBox, selected_option: str): + unit_box.clear() + self.current_option = selected_option + # Use the list of preferred units but fallback to the first 5 if there aren't any for this particular column. + if self.current_option == '': + unit_box.setDisabled(True) + else: + unit_box.setDisabled(False) + unit_options = defaults_or_fallback(self.current_option) + option_symbols = [unit.symbol for unit in unit_options] + for option in option_symbols[:5]: + unit_box.addItem(option) + unit_box.addItem('Select More') + + + def replaceOptions(self, new_options) -> None: + """Replace the old options for the column with new_options""" + self.col_widget.clear() + self.col_widget.addItems(new_options) + + def setCurrentColumn(self, new_column_value: str) -> None: + """Change the current selected column to new_column_value""" + self.col_widget.setCurrentText(new_column_value) + self.updateUnits(self.unit_widget, new_column_value) + + + @Slot() + def onOptionChange(self): + # If the new option is empty string, its probably because the current + # options have been removed. Can safely ignore this. + self.column_changed.emit() + new_option = self.col_widget.currentText() + if new_option == '': + return + try: + self.updateUnits(self.unit_widget, new_option) + except KeyError: + # Means the units for this column aren't known. This shouldn't be + # the case in the real version so for now we'll just clear the unit + # widget. + self.unit_widget.clear() + + @Slot() + def onUnitChange(self): + new_text = self.unit_widget.currentText() + if new_text == 'Select More': + selector = UnitSelector(unit_kinds[self.col_widget.currentText()].name, False) + selector.exec() + # We need the selection unit in the list of options, or else QT has some dodgy behaviour. + self.unit_widget.insertItem(-1, selector.selected_unit.symbol) + self.unit_widget.setCurrentText(selector.selected_unit.symbol) + # This event could get triggered when the units have just been cleared, and not actually updated. We don't want + # to trigger it in this case. + elif not new_text == '': + self.column_changed.emit() + + @property + def currentColumn(self): + """The currently selected column.""" + return self.col_widget.currentText() + + @property + def currentUnit(self) -> NamedUnit: + """The currently selected unit.""" + current_unit_symbol = self.unit_widget.currentText() + for unit in unit_kinds[self.current_option].units: + if current_unit_symbol == unit.symbol: + return unit + # This error shouldn't really happen so if it does, it indicates there is a bug in the code. + raise ValueError("Current unit doesn't seem to exist") + + @currentUnit.setter + def currentUnit(self, new_value: NamedUnit): + self.unit_widget.setCurrentText(new_value.symbol) diff --git a/src/ascii_dialog/constants.py b/src/ascii_dialog/constants.py new file mode 100644 index 0000000000..bf789cf38c --- /dev/null +++ b/src/ascii_dialog/constants.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python + + +TABLE_MAX_ROWS = 1000 +NOFILE_TEXT = "Click the button below to load a file." diff --git a/src/ascii_dialog/dialog.py b/src/ascii_dialog/dialog.py new file mode 100644 index 0000000000..6d22bacd35 --- /dev/null +++ b/src/ascii_dialog/dialog.py @@ -0,0 +1,503 @@ +from os import path + +from PySide6.QtCore import QModelIndex, QPoint, Slot +from PySide6.QtGui import QColor, QCursor, Qt +from PySide6.QtWidgets import ( + QAbstractScrollArea, + QApplication, + QCheckBox, + QComboBox, + QDialog, + QFileDialog, + QHBoxLayout, + QHeaderView, + QLabel, + QMessageBox, + QPushButton, + QSpacerItem, + QSpinBox, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from sasdata.ascii_reader_metadata import AsciiReaderMetadata +from sasdata.dataset_types import DatasetType, dataset_types, one_dim, sesans, two_dim +from sasdata.guess import guess_column_count, guess_columns, guess_starting_position +from sasdata.temp_ascii_reader import AsciiReaderParams, load_data, split_line + +from ascii_dialog.col_editor import ColEditor +from ascii_dialog.constants import TABLE_MAX_ROWS +from ascii_dialog.row_status_widget import RowStatusWidget +from ascii_dialog.selection_menu import SelectionMenu +from ascii_dialog.warning_label import WarningLabel +from metadata_filename_gui.metadata_filename_dialog import MetadataFilenameDialog + +dataset_dictionary = dict([(dataset.name, dataset) for dataset in [one_dim, two_dim, sesans]]) + +class AsciiDialog(QDialog): + """A dialog window allowing the user to adjust various properties regarding + how an ASCII file should be interpreted. This widget allows the user to + visualise what the data will look like with the parameter the user has + selected. + + """ + def __init__(self): + super().__init__() + + self.files: dict[str, list[str]] = {} + self.files_full_path: dict[str, str] = {} + self.files_is_included: dict[str, list[bool]] = {} + # This is useful for whenever the user wants to reopen the metadata editor. + self.internal_metadata: AsciiReaderMetadata = AsciiReaderMetadata() + self.current_filename: str | None = None + + self.seperators: dict[str, bool] = { + 'Comma': True, + 'Whitespace': True, + 'Tab': True + } + + self.setWindowTitle('ASCII File Reader') + + # Filename, unload button, and edit metadata button. + + self.filename_unload_layout = QHBoxLayout() + self.unloadButton = QPushButton("Unload") + self.unloadButton.setDisabled(True) + self.unloadButton.clicked.connect(self.unload) + # Filename chooser + self.filename_chooser = QComboBox() + self.filename_chooser.currentTextChanged.connect(self.updateCurrentFile) + + self.filename_unload_layout.addWidget(self.filename_chooser) + self.filename_unload_layout.addWidget(self.unloadButton) + + + self.select_button = QPushButton("Select File") + self.select_button.clicked.connect(self.loadFile) + + ## Dataset type selection + self.dataset_layout = QHBoxLayout() + self.dataset_label = QLabel("Dataset Type") + self.dataset_combobox = QComboBox() + for name in dataset_types: + self.dataset_combobox.addItem(name) + self.dataset_layout.addWidget(self.dataset_label) + self.dataset_layout.addWidget(self.dataset_combobox) + + ## Seperator + self.sep_layout = QHBoxLayout() + + self.sep_widgets: list[QWidget] = [] + self.sep_label = QLabel('Seperators:') + self.sep_layout.addWidget(self.sep_label) + for seperator_name, value in self.seperators.items(): + check_box = QCheckBox(seperator_name) + check_box.setChecked(value) + check_box.clicked.connect(self.seperatorToggle) + self.sep_widgets.append(check_box) + self.sep_layout.addWidget(check_box) + + ## Starting Line + self.startline_layout = QHBoxLayout() + self.startline_label = QLabel('Starting Line') + self.startline_entry = QSpinBox() + self.startline_entry.setMinimum(1) + self.startline_entry.valueChanged.connect(self.updateStartpos) + self.startline_layout.addWidget(self.startline_label) + self.startline_layout.addWidget(self.startline_entry) + + ## Column Count + self.colcount_layout = QHBoxLayout() + self.colcount_label = QLabel('Number of Columns') + self.colcount_entry = QSpinBox() + self.colcount_entry.setMinimum(1) + self.colcount_entry.valueChanged.connect(self.updateColcount) + self.colcount_layout.addWidget(self.colcount_label) + self.colcount_layout.addWidget(self.colcount_entry) + + ## Column Editor + options = self.datasetOptions() + self.col_editor: ColEditor = ColEditor(self.colcount_entry.value(), options) + self.dataset_combobox.currentTextChanged.connect(self.changeDatasetType) + self.col_editor.column_changed.connect(self.updateColumn) + + ## Data Table + + self.table = QTableWidget() + self.table.show() + # Make the table readonly + self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + # The table's width will always resize to fit the amount of space it has. + self.table.setSizeAdjustPolicy(QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents) + # Add the context menu + self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.table.customContextMenuRequested.connect(self.showContextMenu) + + # Warning Label + self.warning_label: WarningLabel = WarningLabel(self.requiredMissing(), self.duplicateColumns()) + + # Done button + # TODO: Not entirely sure what to call/label this. Just going with 'done' for now. + + self.done_line = QHBoxLayout() + self.cancel_button = QPushButton('Cancel') + self.cancel_button.clicked.connect(self.onCancel) + self.done_line_spacer = QSpacerItem(70, 0) + self.editMetadataButton = QPushButton("Edit Metadata") + self.editMetadataButton.setDisabled(True) + self.editMetadataButton.clicked.connect(self.editMetadata) + self.done_button = QPushButton('Done') + self.done_button.clicked.connect(self.onDoneButton) + self.done_line.addWidget(self.cancel_button) + self.done_line.addItem(self.done_line_spacer) + self.done_line.addWidget(self.editMetadataButton) + self.done_line.addWidget(self.done_button) + + self.layout = QVBoxLayout(self) + + self.layout.addLayout(self.filename_unload_layout) + self.layout.addWidget(self.select_button) + self.layout.addLayout(self.dataset_layout) + self.layout.addLayout(self.sep_layout) + self.layout.addLayout(self.startline_layout) + self.layout.addLayout(self.colcount_layout) + self.layout.addWidget(self.col_editor) + self.layout.addWidget(self.table) + self.layout.addWidget(self.warning_label) + self.layout.addLayout(self.done_line) + + @property + def startingPos(self) -> int: + return self.startline_entry.value() - 1 + + @startingPos.setter + def startingPos(self, value: int): + self.startline_entry.setValue(value + 1) + + @property + def rawCsv(self) -> list[str] | None: + if self.current_filename is None: + return None + return self.files[self.current_filename] + + @property + def rowsIsIncluded(self) -> list[bool] | None: + if self.current_filename is None: + return None + return self.files_is_included[self.current_filename] + + @property + def excludedLines(self) -> set[int]: + return set([i for i, included in enumerate(self.rowsIsIncluded) if not included]) + + def splitLine(self, line: str) -> list[str]: + """Split a line in a CSV file based on which seperators the user has + selected on the widget. + + """ + return split_line(self.seperators, line) + + def attemptGuesses(self) -> None: + """Attempt to guess various parameters of the data to provide some + default values. Uses the guess.py module + + """ + split_csv = [self.splitLine(line.strip()) for line in self.rawCsv] + + # TODO: I'm not sure if there is any point in holding this initial value. Can possibly be refactored. + self.initial_starting_pos = guess_starting_position(split_csv) + + guessed_colcount = guess_column_count(split_csv, self.initial_starting_pos) + self.col_editor.setCols(guessed_colcount) + + columns = guess_columns(guessed_colcount, self.currentDatasetType()) + self.col_editor.setColOrder(columns) + self.colcount_entry.setValue(guessed_colcount) + self.startingPos = self.initial_starting_pos + + def fillTable(self) -> None: + """Write the data to the table based on the parameters the user has + selected. + + """ + + # Don't try to fill the table if there's no data. + if self.rawCsv is None: + return + + self.table.clear() + + col_count = self.colcount_entry.value() + + self.table.setRowCount(min(len(self.rawCsv), TABLE_MAX_ROWS + 1)) + self.table.setColumnCount(col_count + 1) + self.table.setHorizontalHeaderLabels(["Included"] + self.col_editor.colNames()) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + + # Now fill the table with data + for i, row in enumerate(self.rawCsv): + if i == TABLE_MAX_ROWS: + # Fill with elipsis to indicate there is more data. + for j in range(len(row_split)): + elipsis_item = QTableWidgetItem("...") + elipsis_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.table.setItem(i, j, elipsis_item) + break + + if i < len(self.rowsIsIncluded): + initial_state = self.rowsIsIncluded[i] + else: + initial_state = True + self.rowsIsIncluded.append(initial_state) + if i >= self.startingPos: + row_status = RowStatusWidget(initial_state, i) + row_status.status_changed.connect(self.updateRowStatus) + self.table.setCellWidget(i, 0, row_status) + row_split = self.splitLine(row) + for j, col_value in enumerate(row_split): + if j >= col_count: + continue # Ignore rows that have extra columns. + item = QTableWidgetItem(col_value) + self.table.setItem(i, j + 1, item) + self.setRowTypesetting(i, self.rowsIsIncluded[i]) + + self.table.show() + + def currentDatasetType(self) -> DatasetType: + """Get the dataset type that the user has currently selected.""" + return dataset_dictionary[self.dataset_combobox.currentText()] + + def setRowTypesetting(self, row: int, item_checked: bool) -> None: + """Set the typesetting for the given role depending on whether it is to + be included in the data being loaded, or not. + + """ + for column in range(1, self.table.columnCount() + 1): + item = self.table.item(row, column) + if item is None: + continue + item_font = item.font() + if not item_checked or row < self.startingPos: + item.setForeground(QColor.fromString('grey')) + item_font.setStrikeOut(True) + else: + item.setForeground(QApplication.palette().text()) + item_font.setStrikeOut(False) + item.setFont(item_font) + + def updateWarningLabel(self): + required_missing = self.requiredMissing() + duplicates = self.duplicateColumns() + if self.rawCsv is None: + # We don't have any actual data yet so we're just updating the warning based on the column. + self.warning_label.updateWarning(required_missing, duplicates) + else: + self.warning_label.updateWarning(required_missing, duplicates, [self.splitLine(line) for line in self.rawCsv], self.rowsIsIncluded, self.startingPos) + + @Slot() + def loadFile(self) -> None: + """Open the file loading dialog, and load the file the user selects.""" + filenames, result = QFileDialog.getOpenFileNames(self) + # Happens when the user cancels without selecting a file. There isn't a + # file to load in this case. + if result == '': + return + for filename in filenames: + + basename = path.basename(filename) + + try: + with open(filename) as file: + file_csv = file.readlines() + file_csv = [line.strip() for line in file_csv] + # TODO: This assumes that no two files will be loaded with the same + # name. This might not be a reasonable assumption. + self.files[basename] = file_csv + self.files_full_path[basename] = filename + # Reset checkboxes + self.files_is_included[basename] = [] + if len(self.files) == 1: + # Default behaviour is going to be to set this to the first file we load. This seems sensible but + # may provoke further discussion. + self.current_filename = basename + # This will trigger the update current file event which will cause + # the table to be drawn. + self.internal_metadata.init_separator(basename) + self.filename_chooser.addItem(basename) + self.filename_chooser.setCurrentText(basename) + self.internal_metadata.add_file(basename) + + except OSError: + QMessageBox.critical(self, 'File Read Error', f'There was an error reading {basename}') + except UnicodeDecodeError: + QMessageBox.critical(self, 'File Read Error', f"""There was an error decoding {basename}. +This could potentially be because the file {basename} an ASCII format.""") + # Attempt guesses on the first file that was loaded. + self.attemptGuesses() + + @Slot() + def unload(self) -> None: + del self.files[self.current_filename] + self.filename_chooser.removeItem(self.filename_chooser.currentIndex()) + # Filename chooser should now revert back to a different file. + self.updateCurrentFile() + + @Slot() + def updateColcount(self) -> None: + """Triggered when the amount of columns the user has selected has + changed. + + """ + self.col_editor.setCols(self.colcount_entry.value()) + self.fillTable() + self.updateWarningLabel() + + @Slot() + def updateStartpos(self) -> None: + """Triggered when the starting position of the data has changed.""" + self.fillTable() + self.updateWarningLabel() + + @Slot() + def updateSeperator(self) -> None: + """Changed when the user modifies the set of seperators being used.""" + self.fillTable() + self.updateWarningLabel() + + @Slot() + def updateColumn(self) -> None: + """Triggered when any of the columns has been changed.""" + self.fillTable() + self.updateWarningLabel() + + @Slot() + def updateCurrentFile(self) -> None: + """Triggered when the current file (choosen from the file chooser + ComboBox) changes. + + """ + self.current_filename = self.filename_chooser.currentText() + if self.current_filename == '': + self.table.clear() + self.table.setDisabled(True) + self.unloadButton.setDisabled(True) + self.editMetadataButton.setDisabled(True) + # Set this to None because other methods are expecting this. + self.current_filename = None + else: + self.table.setDisabled(False) + self.unloadButton.setDisabled(False) + self.editMetadataButton.setDisabled(False) + self.fillTable() + self.updateWarningLabel() + + @Slot() + def seperatorToggle(self) -> None: + """Triggered when one of the seperator check boxes has been toggled.""" + check_box = self.sender() + self.seperators[check_box.text()] = check_box.isChecked() + self.fillTable() + self.updateWarningLabel() + + @Slot() + def changeDatasetType(self) -> None: + """Triggered when the selected dataset type has changed.""" + options = self.datasetOptions() + self.col_editor.replaceOptions(options) + + # Update columns as they'll be different now. + columns = guess_columns(self.colcount_entry.value(), self.currentDatasetType()) + self.col_editor.setColOrder(columns) + + @Slot() + def updateRowStatus(self, row: int) -> None: + """Triggered when the status of row has changed.""" + new_status = self.table.cellWidget(row, 0).isChecked() + self.rowsIsIncluded[row] = new_status + self.setRowTypesetting(row, new_status) + + @Slot() + def showContextMenu(self, point: QPoint) -> None: + """Show the context menu for the table.""" + context_menu = SelectionMenu(self) + context_menu.select_all_event.connect(self.selectItems) + context_menu.deselect_all_event.connect(self.deselectItems) + context_menu.exec(QCursor.pos()) + + def changeInclusion(self, indexes: list[QModelIndex], new_value: bool): + for index in indexes: + # This will happen if the user has selected a point which exists before the starting line. To prevent an + # error, this code will skip that position. + row = index.row() + if row < self.startingPos: + continue + self.table.cellWidget(row, 0).setChecked(new_value) + self.updateRowStatus(row) + + @Slot() + def selectItems(self) -> None: + """Include all of the items that have been selected in the table.""" + self.changeInclusion(self.table.selectedIndexes(), True) + self.updateWarningLabel() + + @Slot() + def deselectItems(self) -> None: + """Don't include all of the items that have been selected in the table.""" + self.changeInclusion(self.table.selectedIndexes(), False) + self.updateWarningLabel() + + def requiredMissing(self) -> list[str]: + """Returns all the columns that are required by the dataset type but + have not currently been selected. + + """ + dataset = self.currentDatasetType() + missing_columns = [col for col in dataset.required if col not in self.col_editor.colNames()] + return missing_columns + + def duplicateColumns(self) -> set[str]: + """Returns all of the columns which have been selected multiple times.""" + col_names = self.col_editor.colNames() + return set([col for col in col_names if not col == '' and col_names.count(col) > 1]) + + def datasetOptions(self) -> list[str]: + current_dataset_type = self.currentDatasetType() + return current_dataset_type.required + current_dataset_type.optional + [''] + + def onDoneButton(self): + params = AsciiReaderParams( + list(self.files_full_path.values()), + self.col_editor.columns, + self.internal_metadata, + self.startingPos, + self.excludedLines, + self.seperators, + ) + self.params = params + self.accept() + + def onCancel(self): + self.reject() + + def editMetadata(self): + dialog = MetadataFilenameDialog(self.current_filename, self.internal_metadata) + status = dialog.exec() + if status == 1: + self.internal_metadata = dialog.internal_metadata + + +if __name__ == "__main__": + app = QApplication([]) + + dialog = AsciiDialog() + status = dialog.exec() + # 1 means the dialog was accepted. + if status == 1: + loaded = load_data(dialog.params) + for datum in loaded: + print(datum.summary()) + + exit() diff --git a/src/ascii_dialog/row_status_widget.py b/src/ascii_dialog/row_status_widget.py new file mode 100644 index 0000000000..edddbed4de --- /dev/null +++ b/src/ascii_dialog/row_status_widget.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 + +from PySide6.QtCore import Qt, Signal, Slot +from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QWidget + + +class RowStatusWidget(QWidget): + """Widget to toggle whether the row is to be included as part of the data.""" + def __init__(self, initial_value: bool, row: int): + super().__init__() + self.row = row + self.checkbox = QCheckBox() + self.checkbox.setChecked(initial_value) + self.updateLabel() + self.checkbox.stateChanged.connect(self.onStateChange) + self.layout = QHBoxLayout(self) + self.layout.addWidget(self.checkbox, alignment=Qt.AlignmentFlag.AlignCenter) + + status_changed = Signal(int) + def updateLabel(self): + """Update the label of the check box depending on whether it is checked, + or not.""" + pass + + + @Slot() + def onStateChange(self): + self.updateLabel() + self.status_changed.emit(self.row) + + def isChecked(self) -> bool: + return self.checkbox.isChecked() + + def setChecked(self, new_value: bool): + self.checkbox.setChecked(new_value) diff --git a/src/ascii_dialog/selection_menu.py b/src/ascii_dialog/selection_menu.py new file mode 100644 index 0000000000..e275686646 --- /dev/null +++ b/src/ascii_dialog/selection_menu.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 + + +from PySide6.QtCore import Signal +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QMenu, QWidget + + +class SelectionMenu(QMenu): + select_all_event = Signal() + deselect_all_event = Signal() + + def __init__(self, parent: QWidget): + super().__init__(parent) + + select_all = QAction("Select All", parent) + select_all.triggered.connect(self.select_all_event) + + deselect_all = QAction("Deselect All", parent) + deselect_all.triggered.connect(self.deselect_all_event) + + self.addAction(select_all) + self.addAction(deselect_all) diff --git a/src/ascii_dialog/unit_list_widget.py b/src/ascii_dialog/unit_list_widget.py new file mode 100644 index 0000000000..20a7c34ab0 --- /dev/null +++ b/src/ascii_dialog/unit_list_widget.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +from PySide6.QtWidgets import QListWidget, QListWidgetItem + +from sasdata.quantities.units import NamedUnit + + +class UnitListWidget(QListWidget): + def reprUnit(self, unit: NamedUnit) -> str: + return f"{unit.symbol} ({unit.name})" + + def populateList(self, units: list[NamedUnit]) -> None: + self.clear() + self.units = units + for unit in units: + item = QListWidgetItem(self.reprUnit(unit)) + self.addItem(item) + + @property + def selectedUnit(self) -> NamedUnit | None: + return self.units[self.currentRow()] + + def __init__(self): + super().__init__() + self.units: list[NamedUnit] = [] diff --git a/src/ascii_dialog/unit_preference_line.py b/src/ascii_dialog/unit_preference_line.py new file mode 100644 index 0000000000..be6a15da56 --- /dev/null +++ b/src/ascii_dialog/unit_preference_line.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 + +from PySide6.QtCore import Slot +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QWidget + +from sasdata.quantities.units import NamedUnit, UnitGroup + +from ascii_dialog.unit_selector import UnitSelector + + +class UnitPreferenceLine(QWidget): + def __init__(self, column_name: str, initial_unit: NamedUnit, group: UnitGroup): + super().__init__() + + self.group = group + self.current_unit = initial_unit + + self.column_label = QLabel(column_name) + self.unit_button = QPushButton(initial_unit.symbol) + self.unit_button.clicked.connect(self.onUnitPress) + + self.layout = QHBoxLayout(self) + self.layout.addWidget(self.column_label) + self.layout.addWidget(self.unit_button) + + @Slot() + def onUnitPress(self): + picker = UnitSelector(self.group.name, False) + picker.exec() + self.current_unit = picker.selected_unit + self.unit_button.setText(self.current_unit.symbol) diff --git a/src/ascii_dialog/unit_preferences.py b/src/ascii_dialog/unit_preferences.py new file mode 100644 index 0000000000..9152675f41 --- /dev/null +++ b/src/ascii_dialog/unit_preferences.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 + +import random + +from PySide6.QtGui import Qt +from PySide6.QtWidgets import QApplication, QScrollArea, QVBoxLayout, QWidget + +from sasdata.dataset_types import unit_kinds +from sasdata.quantities.units import NamedUnit + +from ascii_dialog.unit_preference_line import UnitPreferenceLine + + +class UnitPreferences(QWidget): + def __init__(self): + super().__init__() + + # TODO: Presumably this will be loaded from some config from somewhere. + # For now just fill it with some placeholder values. + column_names = unit_kinds.keys() + self.columns: dict[str, NamedUnit] = {} + for name in column_names: + self.columns[name] = random.choice(unit_kinds[name].units) + + self.layout = QVBoxLayout(self) + preference_lines = QWidget() + scroll_area = QScrollArea() + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll_layout = QVBoxLayout(preference_lines) + for column_name, unit in self.columns.items(): + line = UnitPreferenceLine(column_name, unit, unit_kinds[column_name]) + scroll_layout.addWidget(line) + + scroll_area.setWidget(preference_lines) + self.layout.addWidget(scroll_area) + + +if __name__ == "__main__": + app = QApplication([]) + + widget = UnitPreferences() + widget.show() + + exit(app.exec()) diff --git a/src/ascii_dialog/unit_selector.py b/src/ascii_dialog/unit_selector.py new file mode 100644 index 0000000000..f039f4b6df --- /dev/null +++ b/src/ascii_dialog/unit_selector.py @@ -0,0 +1,80 @@ +from PySide6.QtCore import Slot +from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QLineEdit, QPushButton, QVBoxLayout + +from sasdata.quantities.units import NamedUnit, UnitGroup, unit_group_names, unit_groups + +from ascii_dialog.unit_list_widget import UnitListWidget + +all_unit_groups = list(unit_groups.values()) + +class UnitSelector(QDialog): + def currentUnitGroup(self) -> UnitGroup: + index = self.unit_type_selector.currentIndex() + return all_unit_groups[index] + + @property + def selected_unit(self) -> NamedUnit | None: + return self.unit_list_widget.selectedUnit + + @Slot() + def onSearchChanged(self): + search_input = self.search_box.text() + current_group = self.currentUnitGroup() + units = current_group.units + if search_input != '': + units = [unit for unit in units if search_input.lower() in unit.name] + self.unit_list_widget.populateList(units) + + + @Slot() + def unitGroupChanged(self): + new_group = self.currentUnitGroup() + self.search_box.setText('') + self.unit_list_widget.populateList(new_group.units) + + @Slot() + def selectUnit(self): + self.accept() + + @Slot() + def selectionChanged(self): + self.select_button.setDisabled(False) + + def __init__(self, default_group='length', allow_group_edit=True): + super().__init__() + + self.unit_type_selector = QComboBox() + self.unit_type_selector.addItems(unit_group_names) + self.unit_type_selector.setCurrentText(default_group) + if not allow_group_edit: + self.unit_type_selector.setDisabled(True) + self.unit_type_selector.currentTextChanged.connect(self.unitGroupChanged) + + self.search_box = QLineEdit() + self.search_box.textChanged.connect(self.onSearchChanged) + self.search_box.setPlaceholderText('Search for a unit...') + + self.unit_list_widget = UnitListWidget() + # TODO: Are they all named units? + self.unit_list_widget.populateList(self.currentUnitGroup().units) + self.unit_list_widget.itemSelectionChanged.connect(self.selectionChanged) + self.unit_list_widget.itemDoubleClicked.connect(self.selectUnit) + + self.select_button = QPushButton('Select Unit') + self.select_button.pressed.connect(self.selectUnit) + self.select_button.setDisabled(True) + + self.layout = QVBoxLayout(self) + self.layout.addWidget(self.unit_type_selector) + self.layout.addWidget(self.search_box) + self.layout.addWidget(self.unit_list_widget) + self.layout.addWidget(self.select_button) + +if __name__ == "__main__": + app = QApplication([]) + + widget = UnitSelector() + widget.exec() + print(widget.selected_unit) + + exit() diff --git a/src/ascii_dialog/warning_label.py b/src/ascii_dialog/warning_label.py new file mode 100644 index 0000000000..ccf2607797 --- /dev/null +++ b/src/ascii_dialog/warning_label.py @@ -0,0 +1,52 @@ +from PySide6.QtWidgets import QLabel + +from ascii_dialog.constants import TABLE_MAX_ROWS + + +class WarningLabel(QLabel): + """Widget to display an appropriate warning message based on whether there + exists columns that are missing, or there are columns that are duplicated. + + """ + def setFontRed(self): + self.setStyleSheet("QLabel { color: red}") + + def setFontOrange(self): + self.setStyleSheet("QLabel { color: orange}") + + def setFontNormal(self): + self.setStyleSheet('') + + def updateWarning(self, missing_columns: list[str], duplicate_columns: list[str], lines: list[list[str]] | None = None, rows_is_included: list[bool] | None = None, starting_pos: int = 0): + """Determine, and set the appropriate warning messages given how many + columns are missing, and how many columns are duplicated.""" + unparsable = 0 + if lines is not None and rows_is_included is not None: + for i, line in enumerate(lines): + # Right now, rows_is_included only includes a limited number of rows as there is a maximum that can be + # shown in the table without it being really laggy. We're just going to assume the lines after it should + # be included. + if (i >= TABLE_MAX_ROWS or rows_is_included[i]) and i >= starting_pos: + # TODO: Is there really no builtin function for this? I don't like using try/except like this. + try: + for item in line: + _ = float(item) + except: + unparsable += 1 + + if len(missing_columns) != 0: + self.setText(f'The following columns are missing: {missing_columns}') + self.setFontRed() + elif len(duplicate_columns) > 0: + self.setText('There are columns which are repeated.') + self.setFontRed() + elif unparsable > 0: + # FIXME: This error message could perhaps be a bit clearer. + self.setText(f'{unparsable} lines failed to be read. They will be ignored.') + self.setFontOrange() + else: + self.setText('') + + def __init__(self, initial_missing_columns, initial_duplicate_classes): + super().__init__() + self.updateWarning(initial_missing_columns, initial_duplicate_classes) diff --git a/src/metadata_filename_gui/metadata_component_selector.py b/src/metadata_filename_gui/metadata_component_selector.py new file mode 100644 index 0000000000..1800bc71c7 --- /dev/null +++ b/src/metadata_filename_gui/metadata_component_selector.py @@ -0,0 +1,58 @@ +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QHBoxLayout, QPushButton, QWidget + +from sasdata.ascii_reader_metadata import AsciiReaderMetadata + + +class MetadataComponentSelector(QWidget): + # Creating a separate signal for this because the custom button may be destroyed/recreated whenever the options are + # redrawn. + + custom_button_pressed = Signal(Qt.MouseButton()) + + def __init__(self, category: str, metadatum: str, filename: str, internal_metadata: AsciiReaderMetadata): + super().__init__() + self.options: list[str] + self.option_buttons: list[QPushButton] + self.layout = QHBoxLayout(self) + self.internal_metadata = internal_metadata + self.metadatum = metadatum + self.category = category + self.filename = filename + + def clear_options(self): + for i in reversed(range(self.layout.count() - 1)): + self.layout.takeAt(i).widget().deleteLater() + + def draw_options(self, new_options: list[str], selected_option: str | None): + self.clear_options() + self.options = new_options + self.option_buttons = [] + for option in self.options: + option_button = QPushButton(option) + option_button.setCheckable(True) + option_button.clicked.connect(self.selection_changed) + option_button.setChecked(option == selected_option) + self.layout.addWidget(option_button) + self.option_buttons.append(option_button) + # This final button is to convert to use custom entry instead of this. + self.custom_entry_button = QPushButton('Custom') + # self.custom_entry_button.clicked.connect(self.custom_button_pressed) + self.custom_entry_button.clicked.connect(self.handle_custom_button) + self.layout.addWidget(self.custom_entry_button) + + def handle_custom_button(self): + self.custom_button_pressed.emit() + + def selection_changed(self): + selected_button: QPushButton = self.sender() + button_index = -1 + for i, button in enumerate(self.option_buttons): + if button != selected_button: + button.setChecked(False) + else: + button_index = i + if selected_button.isChecked(): + self.internal_metadata.update_metadata(self.category, self.metadatum, self.filename, button_index) + else: + self.internal_metadata.clear_metadata(self.category, self.metadatum, self.filename) diff --git a/src/metadata_filename_gui/metadata_custom_selector.py b/src/metadata_filename_gui/metadata_custom_selector.py new file mode 100644 index 0000000000..7a42b1d817 --- /dev/null +++ b/src/metadata_filename_gui/metadata_custom_selector.py @@ -0,0 +1,29 @@ +from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QWidget + +from sasdata.ascii_reader_metadata import AsciiReaderMetadata + + +class MetadataCustomSelector(QWidget): + def __init__(self, category:str, metadatum: str, internal_metadata: AsciiReaderMetadata, filename: str): + super().__init__() + self.internal_metadata = internal_metadata + self.metadatum = metadatum + self.category = category + self.filename = filename + + prexisting_value = self.internal_metadata.get_metadata(category, metadatum, filename) + initial_value = prexisting_value if prexisting_value is not None else '' + self.entry_box = QLineEdit(initial_value) + self.entry_box.textChanged.connect(self.selection_changed) + self.from_filename_button = QPushButton('From Filename') + + self.layout = QHBoxLayout(self) + self.layout.addWidget(self.entry_box) + self.layout.addWidget(self.from_filename_button) + + def selection_changed(self): + new_value = self.entry_box.text() + if new_value != '': + self.internal_metadata.update_metadata(self.category, self.metadatum, self.filename, new_value) + else: + self.internal_metadata.clear_metadata(self.category, self.metadatum, self.filename) diff --git a/src/metadata_filename_gui/metadata_filename_dialog.py b/src/metadata_filename_gui/metadata_filename_dialog.py new file mode 100644 index 0000000000..48f4ae511f --- /dev/null +++ b/src/metadata_filename_gui/metadata_filename_dialog.py @@ -0,0 +1,125 @@ +from sys import argv + +from PySide6.QtWidgets import ( + QApplication, + QButtonGroup, + QDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QRadioButton, + QVBoxLayout, +) + +from sasdata.ascii_reader_metadata import AsciiReaderMetadata + +from metadata_filename_gui.metadata_tree_widget import MetadataTreeWidget + + +def build_font(text: str, classname: str = '') -> str: + match classname: + case 'token': + return f"{text}" + case 'separator': + return f"{text}" + case _: + return text + return f'{text}' + +class MetadataFilenameDialog(QDialog): + def __init__(self, filename: str, initial_metadata: AsciiReaderMetadata): + super().__init__() + + # TODO: Will probably change this default later (or a more sophisticated way of getting this default from the + # filename.) + initial_separator_text = initial_metadata.filename_separator[filename] + + self.setWindowTitle('Metadata') + + self.filename = filename + # Key is the metadatum, value is the component selected for it. + self.internal_metadata = initial_metadata + + self.filename_line_label = QLabel() + self.separate_on_group = QButtonGroup() + self.character_radio = QRadioButton("Character") + self.separate_on_group.addButton(self.character_radio) + self.casing_radio = QRadioButton("Casing") + self.separate_on_group.addButton(self.casing_radio) + if isinstance(initial_separator_text, str): + self.character_radio.setChecked(True) + else: # if bool + self.casing_radio.setChecked(True) + self.separate_on_layout = QHBoxLayout() + self.separate_on_group.buttonToggled.connect(self.update_filename_separation) + self.separate_on_layout.addWidget(self.filename_line_label) + self.separate_on_layout.addWidget(self.character_radio) + self.separate_on_layout.addWidget(self.casing_radio) + + if not any([char.isupper() for char in self.filename]): + self.casing_radio.setDisabled(True) + + self.seperator_chars_label = QLabel('Seperators') + if isinstance(initial_separator_text, str): + self.separator_chars = QLineEdit(initial_separator_text) + else: + self.separator_chars = QLineEdit() + self.separator_chars.textChanged.connect(self.update_filename_separation) + + self.filename_separator_layout = QHBoxLayout() + self.filename_separator_layout.addWidget(self.seperator_chars_label) + self.filename_separator_layout.addWidget(self.separator_chars) + + self.metadata_tree = MetadataTreeWidget(self.internal_metadata) + + # Have to update this now because it relies on the value of the separator, and tree. + self.update_filename_separation() + + self.save_button = QPushButton('Save') + self.save_button.clicked.connect(self.on_save) + + self.layout = QVBoxLayout(self) + self.layout.addLayout(self.separate_on_layout) + self.layout.addLayout(self.filename_separator_layout) + self.layout.addWidget(self.metadata_tree) + self.layout.addWidget(self.save_button) + + def formatted_filename(self) -> str: + sep_str = self.separator_chars.text() + if sep_str == '' or self.casing_radio.isChecked(): + return f'{self.filename}' + # TODO: Won't escape characters; I'll handle that later. + separated = self.internal_metadata.filename_components(self.filename, False, True) + font_elements = '' + for i, token in enumerate(separated): + classname = 'token' if i % 2 == 0 else 'separator' + font_elements += build_font(token, classname) + return font_elements + + def update_filename_separation(self): + if self.casing_radio.isChecked(): + self.separator_chars.setDisabled(True) + else: + self.separator_chars.setDisabled(False) + self.internal_metadata.filename_separator[self.filename] = self.separator_chars.text() if self.character_radio.isChecked() else True + self.internal_metadata.purge_unreachable(self.filename) + self.filename_line_label.setText(f'Filename: {self.formatted_filename()}') + self.metadata_tree.draw_tree(self.filename) + + def on_save(self): + self.accept() + # Don't really need to do anything else. Anyone using this dialog can access the component_metadata dict. + + + +if __name__ == "__main__": + app = QApplication([]) + if len(argv) < 2: + filename = input('Input filename to test: ') + else: + filename = argv[1] + dialog = MetadataFilenameDialog(filename) + status = dialog.exec() + if status == 1: + print(dialog.component_metadata) diff --git a/src/metadata_filename_gui/metadata_selector.py b/src/metadata_filename_gui/metadata_selector.py new file mode 100644 index 0000000000..b7b583d6ca --- /dev/null +++ b/src/metadata_filename_gui/metadata_selector.py @@ -0,0 +1,50 @@ +from PySide6.QtWidgets import QHBoxLayout, QWidget + +from sasdata.ascii_reader_metadata import AsciiReaderMetadata + +from metadata_filename_gui.metadata_component_selector import MetadataComponentSelector +from metadata_filename_gui.metadata_custom_selector import MetadataCustomSelector + + +class MetadataSelector(QWidget): + def __init__(self, category: str, metadatum: str, metadata: AsciiReaderMetadata, filename: str): + super().__init__() + self.category = category + self.metadatum = metadatum + self.metadata: AsciiReaderMetadata = metadata + self.filename = filename + self.options = self.metadata.filename_components(filename) + current_option = self.metadata.get_metadata(self.category, metadatum, filename) + if current_option is None or current_option in self.options: + self.selector_widget = self.new_component_selector() + else: + self.selector_widget = self.new_custom_selector() + + # I can't seem to find any layout that just has one widget in so this will do for now. + self.layout = QHBoxLayout(self) + self.layout.addWidget(self.selector_widget) + + def new_component_selector(self) -> MetadataComponentSelector: + new_selector = MetadataComponentSelector(self.category, self.metadatum, self.filename, self.metadata) + new_selector.custom_button_pressed.connect(self.handle_selector_change) + new_selector.draw_options(self.options, self.metadata.get_metadata(self.category, self.metadatum, self.filename)) + return new_selector + + def new_custom_selector(self) -> MetadataCustomSelector: + new_selector = MetadataCustomSelector(self.category, self.metadatum, self.metadata, self.filename) + new_selector.from_filename_button.clicked.connect(self.handle_selector_change) + return new_selector + + def handle_selector_change(self): + # Need to keep this for when we delete it. + if isinstance(self.selector_widget, MetadataComponentSelector): + # TODO: Will eventually have args + new_widget = self.new_custom_selector() + elif isinstance(self.selector_widget, MetadataCustomSelector): + new_widget = self.new_component_selector() + else: + # Shouldn't happen as selector widget should be either of the above. + return + self.layout.replaceWidget(self.selector_widget, new_widget) + self.selector_widget.deleteLater() + self.selector_widget = new_widget diff --git a/src/metadata_filename_gui/metadata_tree_data.py b/src/metadata_filename_gui/metadata_tree_data.py new file mode 100644 index 0000000000..20e7d66755 --- /dev/null +++ b/src/metadata_filename_gui/metadata_tree_data.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python + +# TODO: This file can probably be deleted. Just want to make sure nothing else +# depends on it. + +metadata = { + 'source': ['name', 'radiation', 'type', 'probe_particle', 'beam_size_name', 'beam_size', 'beam_shape', 'wavelength', 'wavelength_min', 'wavelength_max', 'wavelength_spread'], + 'detector': ['name', 'distance', 'offset', 'orientation', 'beam_center', 'pixel_size', 'slit_length'], + 'aperture': ['name', 'type', 'size_name', 'size', 'distance'], + 'collimation': ['name', 'lengths'], + 'process': ['name', 'date', 'description', 'term', 'notes'], + 'sample': ['name', 'sample_id', 'thickness', 'transmission', 'temperature', 'position', 'orientation', 'details'], + 'transmission_spectrum': ['name', 'timestamp', 'transmission', 'transmission_deviation'], + 'other': ['title', 'run', 'definition'] +} + +initial_metadata_dict = {key: {} for key, _ in metadata.items()} diff --git a/src/metadata_filename_gui/metadata_tree_widget.py b/src/metadata_filename_gui/metadata_tree_widget.py new file mode 100644 index 0000000000..f9bbf57398 --- /dev/null +++ b/src/metadata_filename_gui/metadata_tree_widget.py @@ -0,0 +1,27 @@ +from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem + +from sasdata.ascii_reader_metadata import AsciiReaderMetadata, initial_metadata + +from metadata_filename_gui.metadata_selector import MetadataSelector + + +class MetadataTreeWidget(QTreeWidget): + def __init__(self, metadata: AsciiReaderMetadata): + super().__init__() + self.setColumnCount(2) + self.setHeaderLabels(['Name', 'Filename Components']) + self.metadata: AsciiReaderMetadata = metadata + + def draw_tree(self, full_filename: str): + self.clear() + for top_level, items in initial_metadata.items(): + top_level_item = QTreeWidgetItem([top_level]) + for metadatum in items: + # selector = MetadataComponentSelector(metadatum, self.metadata_dict) + selector = MetadataSelector(top_level, metadatum, self.metadata, full_filename) + metadatum_item = QTreeWidgetItem([metadatum]) + # selector.draw_options(options, metadata_dict.get(metadatum)) + top_level_item.addChild(metadatum_item) + self.setItemWidget(metadatum_item, 1, selector) + self.insertTopLevelItem(0, top_level_item) + self.expandAll() diff --git a/src/sas/qtgui/MainWindow/GuiManager.py b/src/sas/qtgui/MainWindow/GuiManager.py index 6b60993b49..65fcae6989 100644 --- a/src/sas/qtgui/MainWindow/GuiManager.py +++ b/src/sas/qtgui/MainWindow/GuiManager.py @@ -11,6 +11,8 @@ from PySide6.QtWidgets import QDockWidget, QLabel, QProgressBar, QTextBrowser from twisted.internet import reactor +from sasdata.temp_ascii_reader import load_data + import sas # Perspectives @@ -779,6 +781,11 @@ def addTriggers(self): self._workspace.actionWelcomeWidget.triggered.connect(self.actionWelcome) self._workspace.actionCheck_for_update.triggered.connect(self.actionCheck_for_update) self._workspace.actionWhat_s_New.triggered.connect(self.actionWhatsNew) + # Dev + self._workspace.menuDev.menuAction().setVisible(config.DEV_MENU) + self._workspace.actionParticle_Editor.triggered.connect(self.particleEditor) + self._workspace.actionAscii_Loader.triggered.connect(self.asciiLoader) + self.communicate.sendDataToGridSignal.connect(self.showBatchOutput) self.communicate.resultPlotUpdateSignal.connect(self.showFitResults) @@ -1396,3 +1403,22 @@ def saveCustomConfig(self): Save the config file based on current session values """ config.save() + + + # ============= DEV ================= + + def particleEditor(self): + from sas.qtgui.Perspectives.ParticleEditor.DesignWindow import show_particle_editor + show_particle_editor() + + + def asciiLoader(self): + from ascii_dialog.dialog import AsciiDialog + dialog = AsciiDialog() + status = dialog.exec() + if status == 1: + loaded = load_data(dialog.params) + for datum in loaded: + logger.info(datum.summary()) + else: + logger.error('ASCII Reader Closed') diff --git a/src/sas/qtgui/MainWindow/UI/MainWindowUI.ui b/src/sas/qtgui/MainWindow/UI/MainWindowUI.ui index 3829434509..0f3772ac5b 100755 --- a/src/sas/qtgui/MainWindow/UI/MainWindowUI.ui +++ b/src/sas/qtgui/MainWindow/UI/MainWindowUI.ui @@ -164,6 +164,15 @@ + + + Dev + + + + + + @@ -172,6 +181,7 @@ + @@ -639,6 +649,21 @@ What's New + + + Ascii Loader + + + + + Particle Editor + + + + + Dev Tools + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index 21d05636f0..2716ff4a20 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -397,6 +397,13 @@ def qSampling(self) -> QSample: return QSample(min_q, max_q, n_samples, is_log) +particle_editor_window = None +def show_particle_editor(): + global particle_editor_window + + particle_editor_window = DesignWindow() + particle_editor_window.show() + def main(): """ Demo/testing window""" diff --git a/src/sas/system/config/config.py b/src/sas/system/config/config.py index c8b6e579b2..309d2680aa 100644 --- a/src/sas/system/config/config.py +++ b/src/sas/system/config/config.py @@ -162,8 +162,6 @@ def __init__(self): self.SHOW_WELCOME_PANEL = False - - # OpenCL option - should be a string, either, "none", a number, or pair of form "A:B" self.SAS_OPENCL = "none" @@ -213,6 +211,9 @@ def __init__(self): # Last version that the update prompt was dismissed for self.LAST_UPDATE_DISMISSED_VERSION = "5.0.0" + # Developer menu + self.DEV_MENU = False + # # Lock the class down, this is necessary both for # securing the class, and for setting up reading/writing files