Source code for hyloa.gui.main_window

# This file is part of HYLOA - HYsteresis LOop Analyzer.
# Copyright (C) 2024 Francesco Zeno Costanzo

# HYLOA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# HYLOA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.

# You should have received a copy of the GNU General Public License
# along with HYLOA. If not, see <https://www.gnu.org/licenses/>.


"""
Code that manages the main screen.
It is necessary to start the log session in order to trace
everything that is done otherwise it is not possible to start
the analysis. From here the calls to the other functions branch out.
"""

from importlib import resources
import matplotlib.pyplot as plt
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QMdiArea, QMdiSubWindow, 
    QWidget, QVBoxLayout, QPushButton, QMessageBox, QTextEdit,
    QLabel, QDockWidget, QGroupBox, QHBoxLayout, QListWidget,
    QDialog, QInputDialog, QScrollArea, QDesktopWidget, QListWidgetItem,
    QTabWidget
)
from PyQt5.QtGui import QPixmap

# Code for data management
from hyloa.data.io import load_files
from hyloa.data.ws_data import WsData
from hyloa.data.io import duplicate_file
from hyloa.data.io import save_modified_data
from hyloa.data.session import save_current_session
from hyloa.data.session import load_previous_session

# Code for interface
from hyloa.gui.log_window import LogWindow
from hyloa.gui.script_window import ScriptEditor
from hyloa.gui.command_window import CommandWindow
from hyloa.gui.plot_window import PlotControlWidget
from hyloa.gui.worksheet import WorksheetWindow

# Auxiliary code
from hyloa.utils.logging_setup import start_logging
from hyloa.utils.check_version import check_for_updates


[docs] class MainApp(QMainWindow): ''' Class to handle the main window ''' def __init__(self): super().__init__() self.setWindowTitle("Hysteresis Loop Analyzer - tmp session") # Set geometry with percentages of screen size self.screen = QApplication.primaryScreen().availableGeometry() width = int(self.screen.width() * 0.5) height = int(self.screen.height() * 0.5) self.resize(width, height) # Center the window qr = self.frameGeometry() cp = QDesktopWidget().availableGeometry().center() qr.moveCenter(cp) self.move(qr.topLeft()) # MDI area self.mdi_area = QMdiArea() self.setCentralWidget(self.mdi_area) # Attributes to manage information and configuration self.dataframes = [] # List to store loaded DataFrames self.header_lines = [] # List to store the initial lines of files self.logger = None # Logger for the entire application self.logger_path = None # Path to the log file self.fit_results = {} # Dictionary to save fitting results self.number_plots = 0 # Number of all created plots self.figures_map = {} # dict to store all figures self.plot_widgets = {} # {int: PlotControlWidget} self.plot_names = {} # {int: "name of the figure"} self.plot_subwindows = {} # {plot_index: QMdiSubWindow for control panel} self.figure_subwindows = {} # {plot_index: QMdiSubWindow for figure window} self.number_worksheets = 0 # Number of all created worksheets self.worksheet_windows = {} # {int: WorksheetWindow} self.worksheet_names = {} # {int: str} self.worksheet_subwindows = {} # {int: QMdiSubWindow} self.worksheet_dfs = WsData() # To handle worksheet comunication self.plot_tabs = None # To handle the plot control tabs self.plot_control_subwindow = None # To handle the plot control subwindow # Interface self.shell_sub = None self.log_sub = None self.init_sidebar()
[docs] def init_sidebar(self): ''' Create sidebar with buttons ''' dock = QDockWidget(self) dock.setFeatures(QDockWidget.NoDockWidgetFeatures) # === Container of the dock === container = QWidget() container_layout = QVBoxLayout(container) container_layout.setContentsMargins(0, 0, 0, 0) container_layout.setSpacing(0) # === LOGO === logo_label = QLabel() logo_label.setAlignment(Qt.AlignCenter) def load_icon(): with resources.path("hyloa.resources", "icon-5.png") as p: pixmap = QPixmap(str(p)) return pixmap pixmap = load_icon() if pixmap.isNull(): logo_label.setText("Logo not found") else: # Mantein proportion and visibility scaled = pixmap.scaled(128, 128, Qt.KeepAspectRatio, Qt.SmoothTransformation) logo_label.setPixmap(scaled) # Avoid compression logo_label.setFixedHeight(130) container_layout.addWidget(logo_label) # === Scroll area === scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) scroll_content = QWidget() scroll_layout = QVBoxLayout(scroll_content) scroll_layout.setContentsMargins(4, 4, 4, 4) scroll_layout.setSpacing(6) description = QLabel( "To start the analysis, you need to specify a name for the log file.\n" "For more information, use the help button.\n" ) description.setWordWrap(True) scroll_layout.addWidget(description) scroll_layout.addWidget(self.make_group("Strat", [ ("Version", check_for_updates), ("Help", self.help), ("Start Logging", self.conf_logging) ])) scroll_layout.addWidget(self.make_group("File Management", [ ("Load file", self.load_data), ("Show all files", self.show_loaded_files), ("Save file", self.save_data), ("Duplicate file", self.duplicate) ])) scroll_layout.addWidget(self.make_group("Analysis", [ ("Create plot", self.plot), ("Worksheet", self.worksheet), ("Script", self.open_script_editor), ("Annotation", self.open_comment_window) ])) scroll_layout.addWidget(self.make_group("Session", [ ("Load session", self.load_session), ("list of windows", self.show_window_navigator), ("Save session", self.save_session) ])) scroll_layout.addWidget(self.make_group("Exit", [ ("Exit", self.exit_app) ])) scroll_layout.addStretch() # Push everything to the top scroll_area.setWidget(scroll_content) container_layout.addWidget(scroll_area) sidebar_width = int(self.screen.width() * 0.10) # 10% of screen width sidebar_width = max(160, min(sidebar_width, 300)) # Ensure between 160 and 300 container.setFixedWidth(sidebar_width) dock.setWidget(container) self.addDockWidget(Qt.LeftDockWidgetArea, dock)
[docs] def make_button(self, text, callback): ''' Function to create a button with a callback, used for the sidebar Parameters ---------- text : str The text to display on the button callback : function The function to call when the button is clicked Returns ------- btn : QPushButton The created button ''' btn = QPushButton(text) btn.clicked.connect(callback) return btn
[docs] def make_group(self, title, button_info): ''' Function to create a group of buttons with a title, used for the sidebar Parameters ---------- title : str The title of the group button_info : list of tuples A list of tuples where each tuple contains the button text and the callback function Returns ------- group : QGroupBox The created group box containing the buttons ''' group = QGroupBox(title) layout = QVBoxLayout() for label, callback in button_info: btn = QPushButton(label) btn.clicked.connect(callback) layout.addWidget(btn) group.setLayout(layout) return group
#==================== Application Functions ====================#
[docs] def help(self): ''' Function show a short guide ''' help_text = ( "#==========#\n" " Starting \n" "#==========#\n" "In order to start the analysis, you need to specify a name for the log file. " "If you load a previous session, the session log file will be used. " "Log files and session files must be in the same folder. " "If the log file is no longer present, a new one will be created with the same " "name and path as the previous one. \n\n" "#=======#\n" " File \n" "#=======#\n" "To upload files you can use the ”Load file” button. " "All the names of the uploaded files with attached data names, " "since they are all pandas data frames, can be consulted via the show file button.\n" "The ”Save file” button allows you to save a file keeping the same " "header as the original file with which it was uploaded.\n" "If you want to create copies of data to do different tests you can use the ”duplicate file” button. \n\n" "#=========#\n" " Analysis \n" "#=========#\n" "In the analysis section, ”create plot” button opens a command window that allows you to create and customize a graph.\n" "The ”worksheet” button opens a window that allows you to create a worksheet where you can combine data from different files, " "or load from other files. You can make plots, fit data, and simply arithmetic operations between columns.\n" "It is also possible to write and/or load a Python file to perform further analysis of the data via the ”script” button.\n" "”Annotation” opens a text box that allows you to write comments that will then be saved in the log file.\n\n" "#=========#\n" " Session \n " "#=========#\n" "Is possible to use “Save Session” to store the full state: data, plots, layout, fits, etc. the data will be written in a .pkl file.\n" "A previous session can be restored with the “Load Session” button. \n" "At the same time, the button list of windows can be used to visualize a list of all windows open " "in the current session, also with a preview. This is useful when u have a large number of windows and/or some windows are minimized. \n\n" "#=====================#\n" " Shell and log panel \n " "#=====================#\n" " A built-in Python shell is included at the bottom of the interface. \n" "Thehre is also a log Panel that displays real-time logs of all operations." ) msg = QMessageBox(self) msg.setWindowTitle("A Short Guide to Hyloa") msg.setIcon(QMessageBox.Information) text = QTextEdit() text.setPlainText(help_text) text.setReadOnly(True) text.setMinimumSize(700, 500) layout = msg.layout() layout.addWidget(text, 0, 0, 1, layout.columnCount()) msg.exec_()
[docs] def conf_logging(self): ''' Function that call the logging configuration ''' start_logging(self, parent_widget=self)
[docs] def load_data(self): ''' call load files to load data ''' load_files(self) # Pass the class instance as an argument self.refresh_shell_variables()
[docs] def duplicate(self): ''' For duplication file ''' duplicate_file(self)
[docs] def show_loaded_files(self): ''' Show a window listing all currently loaded datasets with their column names and basic information. ''' sub = QMdiSubWindow() sub.setWindowTitle("Loaded Data") text_widget = QTextEdit() text_widget.setReadOnly(True) if not self.dataframes: text_widget.setText("No files loaded.") else: lines = [] for i, df in enumerate(self.dataframes): lines.append(f"=== File {i+1} ===") lines.append(f"Columns ({len(df.columns)}):") for col in df.columns: lines.append(f" - {col}") lines.append("") # empty line separator text_widget.setText("\n".join(lines)) sub.setWidget(text_widget) sub.setMinimumSize(300, 200) sub.adjustSize() self.mdi_area.addSubWindow(sub) sub.show()
[docs] def save_data(self): ''' Call the function to save the modified data. ''' save_modified_data(self, parent_widget=self) # Pass the class instance as an argument
[docs] def worksheet(self): if self.logger is None: QMessageBox.critical(None, "Error", "Cannot create worksheet without starting log") return text, ok = QInputDialog.getText(self, "Worksheet name", "Enter a name for the worksheet:") if not ok or not text.strip(): return self.number_worksheets += 1 ws_idx = self.number_worksheets ws_name = text.strip() worksheet = WorksheetWindow(self.mdi_area, parent=self.mdi_area, name=ws_name, logger=self.logger, app_instance=self) worksheet.setWindowTitle(f"Worksheet - {ws_name}") self.mdi_area.addSubWindow(worksheet) worksheet.show() # Save for session restore self.worksheet_subwindows[ws_idx] = worksheet self.worksheet_windows[ws_idx] = worksheet self.worksheet_names[ws_idx] = ws_name
[docs] def create_plot_tabs(self): ''' Ensure that the plot tabs are initialized. ''' if self.plot_tabs is not None: return if self.plot_tabs is None: self.plot_tabs = QTabWidget() self.plot_tabs.setTabsClosable(True) self.plot_tabs.setMovable(True) self.plot_tabs.tabCloseRequested.connect(self.close_plot_tab) self.plot_control_subwindow = QMdiSubWindow() self.plot_control_subwindow.setWidget(self.plot_tabs) self.plot_control_subwindow.setWindowTitle("Plot Controls") self.plot_control_subwindow.setMinimumSize(600, 400) self.plot_control_subwindow.adjustSize() self.mdi_area.addSubWindow(self.plot_control_subwindow) self.plot_control_subwindow.show()
[docs] def plot(self): ''' Function that create a instance for plot's control panel ''' if self.logger is None: QMessageBox.critical(None, "Error", "Cannot start analysis without starting log") return if len(self.dataframes) == 0: QMessageBox.critical(None, "Error", "No files loaded") return # Ask a name for the plot text, ok = QInputDialog.getText(self, "Plot's name", "Enter a name for the plot:") if not ok or not text.strip(): return # cancel if empty or canceled self.number_plots += 1 custom_name = text.strip() # Create control panel plot_widget = PlotControlWidget(self, self.number_plots, custom_name) self.plot_widgets[self.number_plots] = plot_widget self.plot_names[self.number_plots] = custom_name self.create_plot_tabs() self.plot_tabs.addTab(plot_widget, custom_name) self.plot_tabs.setCurrentWidget(plot_widget) self.plot_widgets[self.number_plots] = plot_widget self.plot_names[self.number_plots] = custom_name
[docs] def close_plot_tab(self, index): ''' Function to close a plot tab ''' widget = self.plot_tabs.widget(index) tab_name = self.plot_tabs.tabText(index) reply = QMessageBox.question( self, "Confirm closing", f"Do you really want to close '{tab_name}'?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) if reply != QMessageBox.Yes: return # Find the plot_id corresponding to this widget plot_id = None for k, v in self.plot_widgets.items(): if v == widget: plot_id = k break if plot_id is not None: # cleanup if plot_id in self.plot_widgets: del self.plot_widgets[plot_id] del self.plot_names[plot_id] if plot_id in self.figures_map: del self.figures_map[plot_id] self.plot_tabs.removeTab(index) widget.deleteLater() if self.plot_tabs.count() == 0: self.plot_control_subwindow.close() self.plot_tabs = None self.plot_control_subwindow = None
[docs] def resizeEvent(self, event): ''' Function that handle the resizing of the main window to adapt the position of the shell and log panels ''' super().resizeEvent(event) QTimer.singleShot(0, self.position_default_panels)
[docs] def open_default_panels(self): ''' Function for opening automatically the shell and the log panel in the bottom part of the main window one next to the other ''' # Create shell self.shell_widget = CommandWindow(self) self.shell_sub = QMdiSubWindow() self.shell_sub.setWidget(self.shell_widget) self.shell_sub.setWindowTitle("Python Shell") self.mdi_area.addSubWindow(self.shell_sub) self.shell_sub.show() # Create log panel log_widget = LogWindow(self) self.log_sub = QMdiSubWindow() self.log_sub.setWidget(log_widget) self.log_sub.setWindowTitle("Log Output") self.mdi_area.addSubWindow(self.log_sub) self.log_sub.show() QTimer.singleShot(0, self.position_default_panels)
[docs] def position_default_panels(self): ''' Function that handle the postion and the automatic scaling of the log and shell panels ''' if not hasattr(self, 'shell_sub') or not hasattr(self, 'log_sub'): return if not self.shell_sub or not self.log_sub: return mdi_size = self.mdi_area.viewport().size() width = mdi_size.width() height = mdi_size.height() half_width = width // 2 panel_height = max(300, height // 4) """# If too small adapt height if panel_height * 2 > height: panel_height = height // 2 self.shell_sub.resize(half_width, panel_height) self.log_sub.resize(half_width, panel_height) self.shell_sub.move(0, height - panel_height) self.log_sub.move(half_width, height - panel_height) """ self.shell_sub.setGeometry(0, height - panel_height, half_width, panel_height) self.log_sub.setGeometry(half_width, height - panel_height, half_width, panel_height)
[docs] def refresh_shell_variables(self): ''' Function to automatically add new variables to the shell context ''' for sub in self.mdi_area.subWindowList(): widget = sub.widget() if isinstance(widget, CommandWindow): widget.refresh_variables()
[docs] def open_script_editor(self): ''' Function to open a window to write some python code ''' if self.logger is None: QMessageBox.critical(None, "Error", "Cannot start analysis without starting log") return editor = ScriptEditor(self) sub = QMdiSubWindow() sub.setWidget(editor.window) sub.setWindowTitle("Editor di Script") sub.setMinimumSize(600, 600) sub.adjustSize() self.mdi_area.addSubWindow(sub) sub.show()
[docs] def open_comment_window(self): ''' Function to open a window to write some comments about the data or something else ''' dialog = QDialog(self) dialog.setWindowTitle("Analysis notes") layout = QVBoxLayout(dialog) layout.addWidget(QLabel("The written notes will be saved in the log file:")) text_edit = QTextEdit() layout.addWidget(text_edit) btn_layout = QHBoxLayout() confirm_btn = QPushButton("Save") cancel_btn = QPushButton("Cancel") btn_layout.addWidget(confirm_btn) btn_layout.addWidget(cancel_btn) layout.addLayout(btn_layout) def save_comment(): comment = text_edit.toPlainText().strip() if comment: if self.logger: self.logger.info(f"[Comment] {comment}") QMessageBox.information(dialog, "Saved", "Comment saved in log.") dialog.accept() else: QMessageBox.warning(dialog, "Empty", "The annotation is empty.") confirm_btn.clicked.connect(save_comment) cancel_btn.clicked.connect(dialog.reject) dialog.exec_()
[docs] def save_session(self): ''' Function that call save_current_session ''' save_current_session(self, parent_widget=self)
[docs] def load_session(self): ''' Function that call load_previous_session ''' load_previous_session(self, parent_widget=self) self.refresh_shell_variables()
[docs] def show_window_navigator(self): ''' Function to navigate through all open windows ''' subwindows = self.mdi_area.subWindowList() if not subwindows: QMessageBox.information(self, "No Window", "There are no open windows.") return dialog = QDialog(self) dialog.setWindowTitle("Windows Navigator") dialog.setMinimumSize(1000, 600) layout = QHBoxLayout(dialog) # ---------------- LEFT SIDE ---------------- left_layout = QVBoxLayout() left_layout.addWidget(QLabel("Open windows:")) list_widget = QListWidget() left_layout.addWidget(list_widget) for sub in subwindows: item = QListWidgetItem(sub.windowTitle()) item.setData(Qt.UserRole, sub) list_widget.addItem(item) # ---------------- RIGHT SIDE ---------------- right_layout = QVBoxLayout() right_layout.addWidget(QLabel("Preview selected window:")) preview_label = QLabel() preview_label.setFixedSize(800, 500) preview_label.setAlignment(Qt.AlignCenter) preview_label.setStyleSheet("border: 1px solid gray; background: white;") right_layout.addWidget(preview_label) activate_button = QPushButton("Activate selected window") right_layout.addWidget(activate_button) layout.addLayout(left_layout, 1) layout.addLayout(right_layout, 2) # ---------------- FUNCTIONS ---------------- def update_preview(): item = list_widget.currentItem() if not item: preview_label.clear() return sub = item.data(Qt.UserRole) # Force repaint to get the latest content (important for minimized windows) sub.repaint() QApplication.processEvents() pixmap = sub.grab() preview = pixmap.scaled( preview_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) preview_label.setPixmap(preview) def activate_window(): item = list_widget.currentItem() if not item: return win = item.data(Qt.UserRole) self.mdi_area.setActiveSubWindow(win) # If the window is minimized, restore it before activating if win.isMinimized(): win.showNormal() win.raise_() dialog.accept() # ---------------- CONNECTIONS ---------------- list_widget.currentItemChanged.connect(update_preview) activate_button.clicked.connect(activate_window) list_widget.itemDoubleClicked.connect(lambda _: activate_window()) # Selection of the first item by default if list_widget.count() > 0: list_widget.setCurrentRow(0) dialog.exec_()
[docs] def exit_app(self): ''' Function for exit button ''' reply = QMessageBox.question(self, "Exit", "Do you want to exit and save your session?", QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel) if reply == QMessageBox.Yes: self.save_session() QApplication.quit() elif reply == QMessageBox.No: QApplication.quit()
# Otherwise (cancel) => do nothing
[docs] def closeEvent(self, event): ''' Intercepts window closing to ask whether to save data ''' if self.logger is None: event.accept() return reply = QMessageBox.question(self, "Exit", "Do you want to exit and save your session?", QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel) if reply == QMessageBox.Yes: self.save_session() event.accept() elif reply == QMessageBox.No: event.accept() else: event.ignore()