Source code for hbat.gui.main_window

"""
Main GUI window for HBAT application.

This module provides the main tkinter interface for the HBAT application,
allowing users to load PDB files, configure analysis parameters, and view results.
"""

import asyncio
import csv
import json
import os
import tkinter as tk
import webbrowser
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from typing import Any, Dict, Optional

import tk_async_execute as tae

from ..constants import APP_NAME, APP_VERSION, GUIDefaults
from ..constants.parameters import ParametersDefault
from ..core.analysis import (
    AnalysisParameters,
    NPMolecularInteractionAnalyzer,
)
from .geometry_cutoffs_dialog import GeometryCutoffsDialog
from .results_panel import ResultsPanel
from .pdb_fixing_dialog import PDBFixingDialog


[docs] class MainWindow: """Main application window for HBAT. This class provides the primary GUI interface for HBAT, including file loading, parameter configuration, analysis execution, and results visualization. :param None: This class takes no parameters during initialization """
[docs] def __init__(self) -> None: """Initialize the main window. Sets up the complete GUI interface including menus, toolbar, main content area, and status bar. :returns: None :rtype: None """ # Initialize HBAT environment first try: from ..core.app_config import get_hbat_config, initialize_hbat_environment initialize_hbat_environment(verbose=True) self.hbat_config = get_hbat_config() except ImportError: self.hbat_config = None self.root = tk.Tk() self.root.title(f"{APP_NAME} v{APP_VERSION}") self.root.geometry(f"{GUIDefaults.WINDOW_WIDTH}x{GUIDefaults.WINDOW_HEIGHT}") self.root.minsize(GUIDefaults.MIN_WINDOW_WIDTH, GUIDefaults.MIN_WINDOW_HEIGHT) # Analysis components self.analyzer: Optional[NPMolecularInteractionAnalyzer] = None self.current_file: Optional[str] = None self.analysis_running = False # Create UI components self._create_menu() self._create_toolbar() self._create_main_content() self._create_status_bar() # Set up event handlers self.root.protocol("WM_DELETE_WINDOW", self._on_closing) # Initialize async executor tae.start()
def _create_menu(self) -> None: """Create the menu bar. Sets up the application menu with File, Analysis, Tools, and Help menus, including keyboard shortcuts and event bindings. :returns: None :rtype: None """ menubar = tk.Menu(self.root) self.root.config(menu=menubar) self.menubar = menubar # Store reference for state updates # File menu file_menu = tk.Menu(menubar, tearoff=0) menubar.add_cascade(label="File", menu=file_menu) file_menu.add_command( label="Open PDB File...", accelerator="Ctrl+O", command=self._open_file ) file_menu.add_separator() file_menu.add_command( label="Save Results...", accelerator="Ctrl+S", command=self._save_results ) file_menu.add_command( label="Save Fixed PDB...", accelerator="Ctrl+Shift+S", command=self._save_fixed_pdb, ) file_menu.add_separator() file_menu.add_command( label="Export All...", accelerator="Ctrl+E", command=self._export_all ) file_menu.add_separator() file_menu.add_command( label="Exit", accelerator="Ctrl+Q", command=self._on_closing ) # Analysis menu analysis_menu = tk.Menu(menubar, tearoff=0) menubar.add_cascade(label="Analysis", menu=analysis_menu) self.analysis_menu = analysis_menu # Store reference analysis_menu.add_command( label="Run Analysis", accelerator="F5", command=self._run_analysis, state=tk.DISABLED, ) self.run_analysis_index = 0 # Index of "Run Analysis" menu item analysis_menu.add_command(label="Clear Results", command=self._clear_results) # Settings menu settings_menu = tk.Menu(menubar, tearoff=0) menubar.add_cascade(label="Settings", menu=settings_menu) settings_menu.add_command( label="Geometry Cutoffs...", command=self._open_parameters_window ) settings_menu.add_command( label="PDB Fixing...", command=self._open_pdb_fixing_window ) settings_menu.add_separator() settings_menu.add_command( label="Manage Presets...", command=self._open_preset_manager ) settings_menu.add_separator() settings_menu.add_command( label="Reset Parameters", command=self._reset_parameters ) # Help menu help_menu = tk.Menu(menubar, tearoff=0) menubar.add_cascade(label="Help", menu=help_menu) help_menu.add_command(label="About", command=self._show_about) help_menu.add_command(label="User Guide", command=self._show_help) # Bind keyboard shortcuts self.root.bind("<Control-o>", lambda e: self._open_file()) self.root.bind("<Control-s>", lambda e: self._save_results()) self.root.bind("<Control-e>", lambda e: self._export_all()) self.root.bind("<Control-q>", lambda e: self._on_closing()) self.root.bind("<F5>", lambda e: self._run_analysis()) self.root.bind("<Control-Shift-S>", lambda e: self._save_fixed_pdb()) def _create_toolbar(self) -> None: """Create the toolbar. Creates a toolbar with progress bar and performance indicator. The toolbar is hidden by default and only shown during operations. :returns: None :rtype: None """ self.toolbar = ttk.Frame(self.root) # Don't pack the toolbar initially - it will be shown when needed # Performance indicator self.performance_label = ttk.Label( self.toolbar, text="⚡", foreground="green", ) # Progress bar self.progress_var = tk.DoubleVar(master=self.root) self.progress_bar = ttk.Progressbar( self.toolbar, variable=self.progress_var, mode="indeterminate" ) def _create_main_content(self) -> None: """Create the main content area. Sets up the main interface with a vertical paned window containing PDB file content on top (30%) and results display area below (70%). :returns: None :rtype: None """ # Create main paned window with vertical orientation main_paned = ttk.PanedWindow(self.root, orient=tk.VERTICAL) main_paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # Top panel - PDB File content (30% of height) top_frame = ttk.Frame(main_paned) main_paned.add(top_frame, weight=3) # 30% weight ratio # Create notebook for PDB file tabs self.left_notebook = ttk.Notebook(top_frame) self.left_notebook.pack(fill=tk.BOTH, expand=True) # File content tab file_frame = ttk.Frame(self.left_notebook) self.left_notebook.add(file_frame, text="PDB File") # Create text widget with both vertical and horizontal scrollbars text_frame = ttk.Frame(file_frame) text_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self.file_text = tk.Text(text_frame, wrap=tk.NONE, font=("Courier", 12)) file_v_scrollbar = ttk.Scrollbar( text_frame, orient=tk.VERTICAL, command=self.file_text.yview ) file_h_scrollbar = ttk.Scrollbar( text_frame, orient=tk.HORIZONTAL, command=self.file_text.xview ) self.file_text.configure( yscrollcommand=file_v_scrollbar.set, xscrollcommand=file_h_scrollbar.set ) self.file_text.grid(row=0, column=0, sticky="nsew") file_v_scrollbar.grid(row=0, column=1, sticky="ns") file_h_scrollbar.grid(row=1, column=0, sticky="ew") text_frame.grid_rowconfigure(0, weight=1) text_frame.grid_columnconfigure(0, weight=1) # Fixed PDB content tab fixed_file_frame = ttk.Frame(self.left_notebook) self.left_notebook.add(fixed_file_frame, text="Fixed PDB") # Create text widget with both vertical and horizontal scrollbars fixed_text_frame = ttk.Frame(fixed_file_frame) fixed_text_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self.fixed_file_text = tk.Text( fixed_text_frame, wrap=tk.NONE, font=("Courier", 12) ) fixed_v_scrollbar = ttk.Scrollbar( fixed_text_frame, orient=tk.VERTICAL, command=self.fixed_file_text.yview ) fixed_h_scrollbar = ttk.Scrollbar( fixed_text_frame, orient=tk.HORIZONTAL, command=self.fixed_file_text.xview ) self.fixed_file_text.configure( yscrollcommand=fixed_v_scrollbar.set, xscrollcommand=fixed_h_scrollbar.set ) self.fixed_file_text.grid(row=0, column=0, sticky="nsew") fixed_v_scrollbar.grid(row=0, column=1, sticky="ns") fixed_h_scrollbar.grid(row=1, column=0, sticky="ew") fixed_text_frame.grid_rowconfigure(0, weight=1) fixed_text_frame.grid_columnconfigure(0, weight=1) # Add context menu for Fixed PDB tab self._create_fixed_pdb_context_menu() # Store parameters for session persistence self.session_parameters = None # Bottom panel - Results (70% of height) bottom_frame = ttk.Frame(main_paned) main_paned.add(bottom_frame, weight=7) # 70% weight ratio self.results_panel = ResultsPanel(bottom_frame) # Set initial pane positions (30% for top, 70% for bottom) # Position the sash at 30% of the total height main_paned.update_idletasks() # Ensure the window is rendered total_height = self.root.winfo_height() - 60 # Account for padding and menu if total_height > 0: sash_position = int(total_height * 0.3) main_paned.sashpos(0, sash_position) def _create_status_bar(self) -> None: """Create the status bar. Creates a status bar at the bottom of the window to display application state and progress information. :returns: None :rtype: None """ self.status_var = tk.StringVar(master=self.root) self.status_var.set("Ready") status_frame = ttk.Frame(self.root) status_frame.pack(fill=tk.X, side=tk.BOTTOM) ttk.Label( status_frame, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W ).pack(fill=tk.X, padx=2, pady=1) def _open_file(self) -> None: """Open a PDB file. Displays a file dialog to select a PDB file, loads its content, and enables analysis functionality. :returns: None :rtype: None """ filename = filedialog.askopenfilename( title="Open PDB File", filetypes=[("PDB files", "*.pdb"), ("All files", "*.*")], ) if filename: try: self.current_file = filename self._load_file_content(filename) # Enable "Run Analysis" menu item self.analysis_menu.entryconfig(self.run_analysis_index, state=tk.NORMAL) self.status_var.set(f"Loaded: {os.path.basename(filename)}") self._clear_results() self._clear_fixed_pdb_content() except Exception as e: messagebox.showerror("Error", f"Failed to load file:\n{str(e)}") self.status_var.set("Error loading file") def _load_file_content(self, filename: str) -> None: """Load and display file content. Reads the PDB file content and displays it in the text widget with syntax highlighting for PDB record types. :param filename: Path to the PDB file to load :type filename: str :returns: None :rtype: None :raises Exception: If file cannot be read """ try: # Show progress for large files self._show_loading_progress("Loading file...") # Load file in chunks to prevent GUI freezing self._load_file_in_chunks(filename) # Add to recent files if config is available if self.hbat_config: self.hbat_config.add_recent_file(filename) except Exception as e: raise Exception(f"Cannot read file: {e}") finally: self._hide_loading_progress() def _load_file_in_chunks(self, filename: str) -> None: """Load file content in chunks to prevent GUI freezing. :param filename: Path to the PDB file to load :type filename: str :returns: None :rtype: None """ chunk_size = 50000 # Process in 50KB chunks self.file_text.delete(1.0, tk.END) try: with open(filename, "r") as file: while True: chunk = file.read(chunk_size) if not chunk: break # Insert chunk and update display self.file_text.insert(tk.END, chunk) self.root.update_idletasks() # Process pending GUI events # Apply syntax highlighting after loading self.root.after_idle(self._highlight_pdb_records) except Exception as e: raise Exception(f"Cannot read file: {e}") def _show_loading_progress(self, message: str) -> None: """Show loading progress indicator. :param message: Status message to display :type message: str :returns: None :rtype: None """ self.status_var.set(message) if not self.toolbar.winfo_ismapped(): self.toolbar.pack(fill=tk.X, padx=5, pady=2) self.progress_bar.pack(fill=tk.BOTH, padx=5, expand=True) self.progress_bar.config(mode="indeterminate") self.progress_bar.start(GUIDefaults.PROGRESS_BAR_INTERVAL) self.root.update_idletasks() def _hide_loading_progress(self) -> None: """Hide loading progress indicator. :returns: None :rtype: None """ self.progress_bar.stop() self.progress_bar.pack_forget() if self.toolbar.winfo_ismapped(): self.toolbar.pack_forget() def _highlight_pdb_records(self) -> None: """Highlight important PDB record types. Applies color coding to different PDB record types (ATOM, HETATM, HEADER, etc.) for better readability. :returns: None :rtype: None """ # Configure text tags self.file_text.tag_configure("atom", foreground="blue") self.file_text.tag_configure("hetatm", foreground="red") self.file_text.tag_configure( "header", foreground="green", font=("Courier", 12, "bold") ) content = self.file_text.get(1.0, tk.END) lines = content.split("\n") for i, line in enumerate(lines): line_start = f"{i+1}.0" line_end = f"{i+1}.end" if line.startswith("ATOM"): self.file_text.tag_add("atom", line_start, line_end) elif line.startswith("HETATM"): self.file_text.tag_add("hetatm", line_start, line_end) elif line.startswith(("HEADER", "TITLE", "COMPND")): self.file_text.tag_add("header", line_start, line_end) def _run_analysis(self) -> None: """Run the molecular interaction analysis. Initiates analysis using async/await pattern to keep GUI responsive. :returns: None :rtype: None """ if not self.current_file: messagebox.showwarning("Warning", "Please open a PDB file first.") return if self.analysis_running: messagebox.showinfo("Info", "Analysis is already running.") return # Get parameters from session storage or use defaults if self.session_parameters: params = self.session_parameters else: # Use default parameters if none have been set params = AnalysisParameters() self.session_parameters = params # Apply PDB fixing parameters if available # Note: Default AnalysisParameters() should have PDB fixing enabled by default if hasattr(self, 'session_pdb_fixing_params') and self.session_pdb_fixing_params: # Update the params object with session PDB fixing settings params.fix_pdb_enabled = self.session_pdb_fixing_params['enabled'] params.fix_pdb_method = self.session_pdb_fixing_params['method'] params.fix_pdb_add_hydrogens = self.session_pdb_fixing_params['add_hydrogens'] params.fix_pdb_add_heavy_atoms = self.session_pdb_fixing_params['add_heavy_atoms'] params.fix_pdb_replace_nonstandard = self.session_pdb_fixing_params['replace_nonstandard'] params.fix_pdb_remove_heterogens = self.session_pdb_fixing_params['remove_heterogens'] params.fix_pdb_keep_water = self.session_pdb_fixing_params['keep_water'] # Start async analysis without popup window tae.async_execute( self._perform_analysis_async(params), visible=False, show_exceptions=False ) async def _perform_analysis_async(self, params: AnalysisParameters) -> None: """Perform the analysis asynchronously to keep GUI responsive. :param params: Analysis parameters to use :type params: AnalysisParameters :returns: None :rtype: None """ try: # Set up UI for analysis self.analysis_running = True self.analysis_menu.entryconfig(self.run_analysis_index, state=tk.DISABLED) # Show toolbar and progress bar if not self.toolbar.winfo_ismapped(): self.toolbar.pack(fill=tk.X, padx=5, pady=2) self.performance_label.pack(side=tk.LEFT, padx=5) self.progress_bar.pack(fill=tk.BOTH, padx=5, expand=True) self.progress_bar.config(mode="indeterminate") self.progress_bar.start(GUIDefaults.PROGRESS_BAR_INTERVAL) self.status_var.set("Running analysis...") # Create analyzer self.analyzer = NPMolecularInteractionAnalyzer(params) # Set up progress callback for direct GUI updates def progress_callback(message: str) -> None: # Update progress directly without creating new async task self.root.after(0, lambda: self.status_var.set(message)) self.analyzer.progress_callback = progress_callback # Run analysis in executor to avoid blocking success = await asyncio.get_event_loop().run_in_executor( None, self.analyzer.analyze_file, self.current_file ) if success: await self._analysis_complete_async() else: await self._analysis_error_async("Analysis failed") except Exception as e: await self._analysis_error_async(str(e)) async def _update_progress_async(self, message: str) -> None: """Update progress message asynchronously. :param message: Progress message to display :type message: str :returns: None :rtype: None """ self.status_var.set(message) async def _analysis_complete_async(self) -> None: """Handle successful analysis completion asynchronously. :returns: None :rtype: None """ self.analysis_running = False self.progress_bar.stop() self.progress_bar.config(mode="determinate") self.progress_var.set(0) # Hide progress bar, performance label, and toolbar self.progress_bar.pack_forget() self.performance_label.pack_forget() self.toolbar.pack_forget() self.analysis_menu.entryconfig(self.run_analysis_index, state=tk.NORMAL) # Update results panel self.results_panel.update_results(self.analyzer) # Update Fixed PDB tab if PDB fixing was applied self._update_fixed_pdb_content() # Update status summary = self.analyzer.get_summary() self.status_var.set( f"Analysis complete - H-bonds: {summary['hydrogen_bonds']['count']}, " f"X-bonds: {summary['halogen_bonds']['count']}, π-interactions: {summary['pi_interactions']['count']}" ) messagebox.showinfo("Success", "Analysis completed successfully!") async def _analysis_error_async(self, error_msg: str) -> None: """Handle analysis error asynchronously. :param error_msg: Error message to display :type error_msg: str :returns: None :rtype: None """ self.analysis_running = False self.progress_bar.stop() self.progress_bar.config(mode="determinate") self.progress_var.set(0) # Hide progress bar, performance label, and toolbar self.progress_bar.pack_forget() self.performance_label.pack_forget() self.toolbar.pack_forget() self.analysis_menu.entryconfig(self.run_analysis_index, state=tk.NORMAL) self.status_var.set("Analysis failed") messagebox.showerror("Analysis Error", f"Analysis failed:\n{error_msg}") def _clear_results(self) -> None: """Clear analysis results. Clears all analysis results from the interface and resets the analyzer state. :returns: None :rtype: None """ self.results_panel.clear_results() self.analyzer = None self._clear_fixed_pdb_content() self.status_var.set("Results cleared") def _save_results(self) -> None: """Save analysis results to file. Displays a file dialog to save analysis results in CSV or JSON format. Requires completed analysis to function. :returns: None :rtype: None """ if not self.analyzer: messagebox.showwarning("Warning", "No results to save. Run analysis first.") return filename = filedialog.asksaveasfilename( title="Save Results", defaultextension=".csv", filetypes=[ ("CSV files", "*.csv"), ("JSON files", "*.json"), ], ) if filename: try: file_path = Path(filename) extension = file_path.suffix.lower() self._export_results_to_file(filename) # Show appropriate success message based on format if extension in [".csv", ".json"]: base_name = file_path.stem directory = file_path.parent messagebox.showinfo( "Success", f"Results saved as separate {extension.upper()} files:\n" f"- {base_name}_h_bonds{extension}\n" f"- {base_name}_x_bonds{extension}\n" f"- {base_name}_pi_interactions{extension}\n" f"- {base_name}_cooperativity_chains{extension}\n\n" f"Location: {directory}", ) self.status_var.set( f"Results saved as multiple {extension.upper()} files" ) else: messagebox.showinfo("Success", f"Results saved to {filename}") self.status_var.set( f"Results saved to {os.path.basename(filename)}" ) except Exception as e: messagebox.showerror("Error", f"Failed to save results:\n{str(e)}") def _export_results_to_file(self, filename: str) -> None: """Export results to a file. Supports multiple formats: - .csv: Separate CSV files for each interaction type (default) - .json: Separate JSON files for each interaction type :param filename: Path to the output file :type filename: str :returns: None :rtype: None """ file_path = Path(filename) extension = file_path.suffix.lower() if extension == ".json": self._export_json_files(file_path) else: # Default to CSV format self._export_csv_files(file_path) def _export_text_file(self, filename: str) -> None: """Export results to a single text file. :param filename: Path to the output file :type filename: str """ with open(filename, "w") as f: f.write("HBAT Analysis Results\n") f.write("=" * 50 + "\n\n") if self.current_file: f.write(f"Input file: {self.current_file}\n") f.write(f"Analysis engine: HBAT\n") f.write(f"HBAT version: {APP_VERSION}\n\n") # Write summary summary = self.analyzer.get_summary() f.write("Summary:\n") f.write(f" Hydrogen bonds: {summary['hydrogen_bonds']['count']}\n") f.write(f" Halogen bonds: {summary['halogen_bonds']['count']}\n") f.write(f" π interactions: {summary['pi_interactions']['count']}\n") f.write(f" Total interactions: {summary['total_interactions']}\n\n") # Write detailed results f.write("Hydrogen Bonds:\n") f.write("-" * 30 + "\n") for hb in self.analyzer.hydrogen_bonds: f.write(f"{hb}\n") f.write("\nHalogen Bonds:\n") f.write("-" * 30 + "\n") for xb in self.analyzer.halogen_bonds: f.write(f"{xb}\n") f.write("\nπ Interactions:\n") f.write("-" * 30 + "\n") for pi in self.analyzer.pi_interactions: f.write(f"{pi}\n") # Write cooperativity chains if available if ( hasattr(self.analyzer, "cooperativity_chains") and self.analyzer.cooperativity_chains ): f.write("\nCooperativity Chains:\n") f.write("-" * 30 + "\n") for chain in self.analyzer.cooperativity_chains: f.write(f"{chain}\n") def _export_csv_files(self, base_path: Path) -> None: """Export results to separate CSV files for each interaction type. :param base_path: Base path for the files (extension will be replaced) :type base_path: Path """ base_name = base_path.stem directory = base_path.parent # Export hydrogen bonds if self.analyzer.hydrogen_bonds: hb_file = directory / f"{base_name}_h_bonds.csv" self._write_hydrogen_bonds_csv(hb_file) # Export halogen bonds if self.analyzer.halogen_bonds: xb_file = directory / f"{base_name}_x_bonds.csv" self._write_halogen_bonds_csv(xb_file) # Export π interactions if self.analyzer.pi_interactions: pi_file = directory / f"{base_name}_pi_interactions.csv" self._write_pi_interactions_csv(pi_file) # Export cooperativity chains if available if ( hasattr(self.analyzer, "cooperativity_chains") and self.analyzer.cooperativity_chains ): chains_file = directory / f"{base_name}_cooperativity_chains.csv" self._write_cooperativity_chains_csv(chains_file) def _export_json_files(self, base_path: Path) -> None: """Export results to separate JSON files for each interaction type. :param base_path: Base path for the files (extension will be replaced) :type base_path: Path """ base_name = base_path.stem directory = base_path.parent # Export hydrogen bonds if self.analyzer.hydrogen_bonds: hb_file = directory / f"{base_name}_h_bonds.json" self._write_hydrogen_bonds_json(hb_file) # Export halogen bonds if self.analyzer.halogen_bonds: xb_file = directory / f"{base_name}_x_bonds.json" self._write_halogen_bonds_json(xb_file) # Export π interactions if self.analyzer.pi_interactions: pi_file = directory / f"{base_name}_pi_interactions.json" self._write_pi_interactions_json(pi_file) # Export cooperativity chains if available if ( hasattr(self.analyzer, "cooperativity_chains") and self.analyzer.cooperativity_chains ): chains_file = directory / f"{base_name}_cooperativity_chains.json" self._write_cooperativity_chains_json(chains_file) def _write_hydrogen_bonds_csv(self, filename: Path) -> None: """Write hydrogen bonds to CSV file.""" with open(filename, "w", newline="", encoding="utf-8") as csvfile: writer = csv.writer(csvfile) writer.writerow( [ "Donor_Residue", "Donor_Atom", "Hydrogen_Atom", "Acceptor_Residue", "Acceptor_Atom", "Distance_Angstrom", "Angle_Degrees", "Donor_Acceptor_Distance_Angstrom", "Bond_Type", "B/S_Interaction", "D-A_Properties", ] ) for hb in self.analyzer.hydrogen_bonds: writer.writerow( [ hb.donor_residue, hb.donor.name, hb.hydrogen.name, hb.acceptor_residue, hb.acceptor.name, f"{hb.distance:.3f}", f"{hb.angle * 180 / 3.14159:.1f}", f"{hb.donor_acceptor_distance:.3f}", hb.bond_type, hb.get_backbone_sidechain_interaction(), hb.donor_acceptor_properties, ] ) def _write_halogen_bonds_csv(self, filename: Path) -> None: """Write halogen bonds to CSV file.""" with open(filename, "w", newline="", encoding="utf-8") as csvfile: writer = csv.writer(csvfile) writer.writerow( [ "Halogen_Residue", "Halogen_Atom", "Acceptor_Residue", "Acceptor_Atom", "Distance_Angstrom", "Angle_Degrees", "Bond_Type", "B/S_Interaction", "D-A_Properties", ] ) for xb in self.analyzer.halogen_bonds: bs_interaction = xb.get_backbone_sidechain_interaction() da_properties = xb.donor_acceptor_properties writer.writerow( [ xb.donor_residue, xb.halogen.name, xb.acceptor_residue, xb.acceptor.name, f"{xb.distance:.3f}", f"{xb.angle * 180 / 3.14159:.1f}", xb.bond_type, bs_interaction, da_properties, ] ) def _write_pi_interactions_csv(self, filename: Path) -> None: """Write π interactions to CSV file.""" with open(filename, "w", newline="", encoding="utf-8") as csvfile: writer = csv.writer(csvfile) writer.writerow( [ "Donor_Residue", "Donor_Atom", "Hydrogen_Atom", "Pi_Residue", "Distance_Angstrom", "Angle_Degrees", "B/S_Interaction", "D-A_Properties", ] ) for pi in self.analyzer.pi_interactions: writer.writerow( [ pi.donor_residue, pi.donor.name, pi.hydrogen.name, pi.pi_residue, f"{pi.distance:.3f}", f"{pi.angle * 180 / 3.14159:.1f}", pi.get_backbone_sidechain_interaction(), pi.donor_acceptor_properties, ] ) def _write_cooperativity_chains_csv(self, filename: Path) -> None: """Write cooperativity chains to CSV file.""" with open(filename, "w", newline="", encoding="utf-8") as csvfile: writer = csv.writer(csvfile) writer.writerow(["Chain_ID", "Chain_Length", "Chain_Type", "Interactions"]) for i, chain in enumerate(self.analyzer.cooperativity_chains): interactions_str = " -> ".join( [ f"{interaction.get_donor_residue()}({interaction.get_donor_atom().name if interaction.get_donor_atom() else '?'})" for interaction in chain.interactions ] ) writer.writerow( [i + 1, chain.chain_length, chain.chain_type, interactions_str] ) def _write_hydrogen_bonds_json(self, filename: Path) -> None: """Write hydrogen bonds to JSON file.""" data = { "metadata": { "input_file": self.current_file, "analysis_engine": "HBAT", "version": APP_VERSION, "interaction_type": "Hydrogen Bonds", }, "interactions": [], } for hb in self.analyzer.hydrogen_bonds: data["interactions"].append( { "donor_residue": hb.donor_residue, "donor_atom": hb.donor.name, "hydrogen_atom": hb.hydrogen.name, "acceptor_residue": hb.acceptor_residue, "acceptor_atom": hb.acceptor.name, "distance_angstrom": round(hb.distance, 3), "angle_degrees": round(hb.angle * 180 / 3.14159, 1), "donor_acceptor_distance_angstrom": round( hb.donor_acceptor_distance, 3 ), "bond_type": hb.bond_type, "backbone_sidechain_interaction": hb.get_backbone_sidechain_interaction(), "donor_acceptor_properties": hb.donor_acceptor_properties, } ) with open(filename, "w", encoding="utf-8") as jsonfile: json.dump(data, jsonfile, indent=2, ensure_ascii=False) def _write_halogen_bonds_json(self, filename: Path) -> None: """Write halogen bonds to JSON file.""" data = { "metadata": { "input_file": self.current_file, "analysis_engine": "HBAT", "version": APP_VERSION, "interaction_type": "Halogen Bonds", }, "interactions": [], } for xb in self.analyzer.halogen_bonds: bs_interaction = xb.get_backbone_sidechain_interaction() da_properties = xb.donor_acceptor_properties data["interactions"].append( { "halogen_residue": xb.donor_residue, "halogen_atom": xb.halogen.name, "acceptor_residue": xb.acceptor_residue, "acceptor_atom": xb.acceptor.name, "distance_angstrom": round(xb.distance, 3), "angle_degrees": round(xb.angle * 180 / 3.14159, 1), "bond_type": xb.bond_type, "backbone_sidechain_interaction": bs_interaction, "donor_acceptor_properties": da_properties, } ) with open(filename, "w", encoding="utf-8") as jsonfile: json.dump(data, jsonfile, indent=2, ensure_ascii=False) def _write_pi_interactions_json(self, filename: Path) -> None: """Write π interactions to JSON file.""" data = { "metadata": { "input_file": self.current_file, "analysis_engine": "HBAT", "version": APP_VERSION, "interaction_type": "Pi Interactions", }, "interactions": [], } for pi in self.analyzer.pi_interactions: data["interactions"].append( { "donor_residue": pi.donor_residue, "donor_atom": pi.donor.name, "hydrogen_atom": pi.hydrogen.name, "pi_residue": pi.pi_residue, "distance_angstrom": round(pi.distance, 3), "angle_degrees": round(pi.angle * 180 / 3.14159, 1), "backbone_sidechain_interaction": pi.get_backbone_sidechain_interaction(), "donor_acceptor_properties": pi.donor_acceptor_properties, } ) with open(filename, "w", encoding="utf-8") as jsonfile: json.dump(data, jsonfile, indent=2, ensure_ascii=False) def _write_cooperativity_chains_json(self, filename: Path) -> None: """Write cooperativity chains to JSON file.""" data = { "metadata": { "input_file": self.current_file, "analysis_engine": "HBAT", "version": APP_VERSION, "interaction_type": "Cooperativity Chains", }, "chains": [], } for i, chain in enumerate(self.analyzer.cooperativity_chains): chain_data = { "chain_id": i + 1, "chain_length": chain.chain_length, "chain_type": chain.chain_type, "interactions": [], } for interaction in chain.interactions: interaction_data = { "donor_residue": interaction.get_donor_residue(), "acceptor_residue": interaction.get_acceptor_residue(), "interaction_type": interaction.get_interaction_type(), } # Add donor atom if available donor_atom = interaction.get_donor_atom() if donor_atom: interaction_data["donor_atom"] = donor_atom.name # Add acceptor atom if available acceptor_atom = interaction.get_acceptor_atom() if acceptor_atom: interaction_data["acceptor_atom"] = acceptor_atom.name chain_data["interactions"].append(interaction_data) data["chains"].append(chain_data) with open(filename, "w", encoding="utf-8") as jsonfile: json.dump(data, jsonfile, indent=2, ensure_ascii=False) def _export_all(self) -> None: """Export all results in multiple formats. Exports analysis results to a directory in multiple file formats for comprehensive data preservation. :returns: None :rtype: None """ if not self.analyzer: messagebox.showwarning( "Warning", "No results to export. Run analysis first." ) return directory = filedialog.askdirectory(title="Select Export Directory") if directory: try: base_name = ( os.path.splitext(os.path.basename(self.current_file))[0] if self.current_file else "hbat_results" ) # Export text summary self._export_results_to_file( os.path.join(directory, f"{base_name}_summary.txt") ) # Export Fixed PDB if available summary = self.analyzer.get_summary() pdb_info = summary.get("pdb_fixing", {}) if pdb_info.get("applied", False): fixed_pdb_path = os.path.join(directory, f"{base_name}_fixed.pdb") try: fixed_content = self._generate_pdb_content_from_atoms() with open(fixed_pdb_path, "w") as f: f.write(fixed_content) except Exception as e: print(f"Warning: Could not export fixed PDB: {e}") messagebox.showinfo("Success", f"Results exported to {directory}") self.status_var.set("All results exported") except Exception as e: messagebox.showerror("Error", f"Failed to export results:\n{str(e)}") def _reset_parameters(self) -> None: """Reset analysis parameters to defaults. Restores all analysis parameters to their default values as defined in the application constants. :returns: None :rtype: None """ # Reset session parameters to defaults self.session_parameters = AnalysisParameters() self.status_var.set("Parameters reset to defaults") def _show_about(self) -> None: """Show about dialog. Displays application information including version, authors, and institutional affiliation. :returns: None :rtype: None """ about_text = f""" {APP_NAME} v{APP_VERSION} A high-performance tool for analyzing molecular interactions in protein structures. Features: • Hydrogen bond detection • Halogen bond analysis • π-interaction identification • High-performance analysis engine • Comprehensive visualization and export options Author: Abhishek Tiwari """ messagebox.showinfo("About HBAT", about_text.strip()) def _open_parameters_window(self) -> None: """Open geometry cutoffs configuration dialog. Creates a modal dialog for configuring geometry cutoff parameters, preserving any existing parameter values for the session. :returns: None :rtype: None """ # Get current session parameters or defaults current_params = self.session_parameters or AnalysisParameters() # Create dialog dialog = GeometryCutoffsDialog(self.root, current_params) # Get results result = dialog.get_result() if result: # Store the updated parameters in session self.session_parameters = result self.status_var.set("Geometry cutoffs updated") def _open_pdb_fixing_window(self) -> None: """Open PDB fixing settings dialog. Creates a modal dialog for configuring PDB fixing parameters. :returns: None :rtype: None """ # Create dialog dialog = PDBFixingDialog(self.root) # Set current parameters if available if hasattr(self, 'session_pdb_fixing_params') and self.session_pdb_fixing_params: dialog.set_parameters(self.session_pdb_fixing_params) # Get results result = dialog.get_parameters() if result: # Store the PDB fixing parameters self.session_pdb_fixing_params = result # Update the status bar if appropriate if hasattr(self, 'status_bar'): fixing_status = "enabled" if result["enabled"] else "disabled" self.status_bar.config(text=f"PDB fixing {fixing_status} ({result['method']})") # Clear status after 3 seconds self.root.after(3000, lambda: self.status_bar.config(text="Ready")) def _show_help(self) -> None: """Show help dialog. Opens the HBAT documentation website in the default web browser. :returns: None :rtype: None """ webbrowser.open("https://hbat.abhishek-tiwari.com") def _update_fixed_pdb_content(self) -> None: """Update the Fixed PDB tab with the processed structure content. Shows the PDB structure after any fixing has been applied, or indicates that no fixing was done. :returns: None :rtype: None """ if not self.analyzer: self._clear_fixed_pdb_content() return summary = self.analyzer.get_summary() pdb_info = summary.get("pdb_fixing", {}) self.fixed_file_text.delete(1.0, tk.END) if pdb_info.get("applied", False): # PDB fixing was applied - show the fixed structure from saved file try: fixed_file_path = pdb_info.get("fixed_file_path") if fixed_file_path and os.path.exists(fixed_file_path): # Read content from the saved fixed PDB file with open(fixed_file_path, "r") as f: fixed_content = f.read() self.fixed_file_text.insert(1.0, fixed_content) self._highlight_pdb_records_in_widget(self.fixed_file_text) else: # Fallback to generating content from atoms fixed_content = self._generate_pdb_content_from_atoms() self.fixed_file_text.insert(1.0, fixed_content) self._highlight_pdb_records_in_widget(self.fixed_file_text) # Show tab title indicating changes self.left_notebook.tab(1, text="Fixed PDB ✓") except Exception as e: self.fixed_file_text.insert( tk.END, f"Error loading fixed PDB content: {e}\n" ) self.fixed_file_text.insert( tk.END, "Original structure was used for analysis." ) else: # No PDB fixing applied if "error" in pdb_info: self.fixed_file_text.insert(tk.END, "PDB Fixing Status: Failed\n") self.fixed_file_text.insert(tk.END, f"Error: {pdb_info['error']}\n\n") self.fixed_file_text.insert( tk.END, "The original structure was used for analysis.\n" ) self.fixed_file_text.insert( tk.END, "Consider enabling PDB fixing in the analysis parameters." ) else: self.fixed_file_text.insert( tk.END, "PDB Fixing Status: Not Applied\n\n" ) self.fixed_file_text.insert( tk.END, "The original structure was used for analysis without modifications.\n\n", ) self.fixed_file_text.insert(tk.END, "To apply PDB fixing:\n") self.fixed_file_text.insert( tk.END, "1. Open Settings → Geometry Cutoffs\n" ) self.fixed_file_text.insert(tk.END, "2. Enable 'Fix PDB' option\n") self.fixed_file_text.insert( tk.END, "3. Select fixing method (OpenBabel or PDBFixer)\n" ) self.fixed_file_text.insert(tk.END, "4. Re-run the analysis") # Show tab title indicating no changes self.left_notebook.tab(1, text="Fixed PDB") def _clear_fixed_pdb_content(self) -> None: """Clear the Fixed PDB tab content. :returns: None :rtype: None """ self.fixed_file_text.delete(1.0, tk.END) self.fixed_file_text.insert(tk.END, "No analysis results available.\n\n") self.fixed_file_text.insert( tk.END, "Please load a PDB file and run analysis to see the processed structure.", ) self.left_notebook.tab(1, text="Fixed PDB") def _generate_pdb_content_from_atoms(self) -> str: """Generate PDB format content from the analyzer's atoms. Creates PDB format text from the processed atom list, which may include atoms added during PDB fixing. :returns: PDB format content :rtype: str :raises Exception: If atoms cannot be converted to PDB format """ if not self.analyzer or not self.analyzer.parser.atoms: return "No atoms available in processed structure." lines = [] # Add header information lines.append("REMARK 1 PROCESSED BY HBAT") lines.append( "REMARK 1 THIS STRUCTURE MAY INCLUDE MODIFICATIONS FROM PDB FIXING" ) lines.append("REMARK 1") # Add PDB fixing information to header summary = self.analyzer.get_summary() pdb_info = summary.get("pdb_fixing", {}) if pdb_info.get("applied", False): lines.append(f"REMARK 2 PDB FIXING METHOD: {pdb_info['method'].upper()}") lines.append(f"REMARK 2 ORIGINAL ATOMS: {pdb_info['original_atoms']}") lines.append(f"REMARK 2 FIXED ATOMS: {pdb_info['fixed_atoms']}") if pdb_info.get("added_hydrogens", 0) > 0: lines.append( f"REMARK 2 ADDED HYDROGENS: {pdb_info['added_hydrogens']}" ) lines.append(f"REMARK 2 REDETECTED BONDS: {pdb_info['redetected_bonds']}") lines.append("REMARK 2") # Convert atoms to PDB format for atom in self.analyzer.parser.atoms: line = self._atom_to_pdb_line(atom) lines.append(line) lines.append("END") return "\n".join(lines) def _atom_to_pdb_line(self, atom) -> str: """Convert an Atom object to PDB format line. :param atom: Atom object to convert :type atom: Atom :returns: PDB format line :rtype: str """ # PDB format: ATOM/HETATM with specific column positions return ( f"{atom.record_type:<6}" # Record type (ATOM/HETATM) f"{atom.serial:>5} " # Atom serial number f"{atom.name:<4}" # Atom name f"{atom.alt_loc:>1}" # Alternate location f"{atom.res_name:>3} " # Residue name f"{atom.chain_id:>1}" # Chain ID f"{atom.res_seq:>4}" # Residue sequence number f"{atom.i_code:>1} " # Insertion code f"{atom.coords.x:>8.3f}" # X coordinate f"{atom.coords.y:>8.3f}" # Y coordinate f"{atom.coords.z:>8.3f}" # Z coordinate f"{atom.occupancy:>6.2f}" # Occupancy f"{atom.temp_factor:>6.2f}" # Temperature factor f" " # Blank spaces f"{atom.element:>2}" # Element symbol f"{atom.charge:>2}" # Charge ) def _highlight_pdb_records_in_widget(self, text_widget) -> None: """Apply PDB record highlighting to a specific text widget. :param text_widget: Text widget to apply highlighting to :type text_widget: tk.Text :returns: None :rtype: None """ # Configure text tags text_widget.tag_configure("atom", foreground="blue") text_widget.tag_configure("hetatm", foreground="red") text_widget.tag_configure( "remark", foreground="green", font=("Courier", 12, "bold") ) content = text_widget.get(1.0, tk.END) lines = content.split("\n") for i, line in enumerate(lines): line_start = f"{i+1}.0" line_end = f"{i+1}.end" if line.startswith("ATOM"): text_widget.tag_add("atom", line_start, line_end) elif line.startswith("HETATM"): text_widget.tag_add("hetatm", line_start, line_end) elif line.startswith("REMARK"): text_widget.tag_add("remark", line_start, line_end) def _create_fixed_pdb_context_menu(self) -> None: """Create context menu for the Fixed PDB text widget. :returns: None :rtype: None """ # Create context menu self.fixed_pdb_context_menu = tk.Menu(self.root, tearoff=0) self.fixed_pdb_context_menu.add_command( label="Save Fixed PDB...", command=self._save_fixed_pdb ) self.fixed_pdb_context_menu.add_separator() self.fixed_pdb_context_menu.add_command( label="Select All", command=lambda: self.fixed_file_text.tag_add(tk.SEL, "1.0", tk.END), ) self.fixed_pdb_context_menu.add_command( label="Copy", command=lambda: self.root.clipboard_clear() or self.root.clipboard_append(self.fixed_file_text.selection_get()), ) # Bind right-click to show context menu self.fixed_file_text.bind("<Button-3>", self._show_fixed_pdb_context_menu) def _show_fixed_pdb_context_menu(self, event) -> None: """Show context menu for Fixed PDB tab. :param event: Mouse event :type event: tkinter.Event :returns: None :rtype: None """ try: self.fixed_pdb_context_menu.tk_popup(event.x_root, event.y_root) finally: self.fixed_pdb_context_menu.grab_release() def _save_fixed_pdb(self) -> None: """Save the Fixed PDB content to a file. :returns: None :rtype: None """ if not self.analyzer: messagebox.showwarning("Warning", "No analysis results available to save.") return # Check if PDB fixing was actually applied summary = self.analyzer.get_summary() pdb_info = summary.get("pdb_fixing", {}) if not pdb_info.get("applied", False): result = messagebox.askyesno( "No PDB Fixing Applied", "PDB fixing was not applied to this structure. " "The saved file will be identical to the original PDB.\n\n" "Do you want to continue?", ) if not result: return # Get save filename filename = filedialog.asksaveasfilename( title="Save Fixed PDB", defaultextension=".pdb", filetypes=[ ("PDB files", "*.pdb"), ("Text files", "*.txt"), ("All files", "*.*"), ], initialname=f"{os.path.splitext(os.path.basename(self.current_file or 'structure'))[0]}_fixed.pdb", ) if filename: try: # Try to copy from the saved fixed file first fixed_file_path = pdb_info.get("fixed_file_path") if ( pdb_info.get("applied", False) and fixed_file_path and os.path.exists(fixed_file_path) ): # Copy the existing fixed file import shutil shutil.copy2(fixed_file_path, filename) else: # Fallback to saving from text widget content content = self.fixed_file_text.get(1.0, tk.END) with open(filename, "w") as f: f.write(content) messagebox.showinfo("Success", f"Fixed PDB saved to {filename}") self.status_var.set(f"Fixed PDB saved to {os.path.basename(filename)}") except Exception as e: messagebox.showerror("Error", f"Failed to save fixed PDB:\n{str(e)}") def _open_preset_manager(self) -> None: """Open the preset manager dialog. Creates a standalone preset manager that can load/save presets and apply them to the current session parameters. :returns: None :rtype: None """ from .preset_manager_dialog import PresetManagerDialog # Get current session parameters or defaults current_params = self.session_parameters or AnalysisParameters() # Open preset manager dialog = PresetManagerDialog(self.root, current_params) result = dialog.get_result() if result: # Apply the loaded preset to session parameters self._apply_preset_to_session(result) self.status_var.set("Preset loaded and applied to session") def _apply_preset_to_session(self, preset_data: Dict[str, Any]) -> None: """Apply preset data to session parameters. :param preset_data: Preset data to apply :type preset_data: Dict[str, Any] :returns: None :rtype: None """ if "parameters" not in preset_data: messagebox.showerror("Error", "Invalid preset format: missing 'parameters' section") return params_data = preset_data["parameters"] # Create new AnalysisParameters with preset values kwargs = {} # Apply hydrogen bond parameters if "hydrogen_bonds" in params_data: hb = params_data["hydrogen_bonds"] kwargs.update({ "hb_distance_cutoff": hb.get("h_a_distance_cutoff", ParametersDefault.HB_DISTANCE_CUTOFF), "hb_angle_cutoff": hb.get("dha_angle_cutoff", ParametersDefault.HB_ANGLE_CUTOFF), "hb_donor_acceptor_cutoff": hb.get("d_a_distance_cutoff", ParametersDefault.HB_DA_DISTANCE) }) # Apply halogen bond parameters if "halogen_bonds" in params_data: xb = params_data["halogen_bonds"] kwargs.update({ "xb_distance_cutoff": xb.get("x_a_distance_cutoff", ParametersDefault.XB_DISTANCE_CUTOFF), "xb_angle_cutoff": xb.get("dxa_angle_cutoff", ParametersDefault.XB_ANGLE_CUTOFF) }) # Apply π interaction parameters if "pi_interactions" in params_data: pi = params_data["pi_interactions"] kwargs.update({ "pi_distance_cutoff": pi.get("h_pi_distance_cutoff", ParametersDefault.PI_DISTANCE_CUTOFF), "pi_angle_cutoff": pi.get("dh_pi_angle_cutoff", ParametersDefault.PI_ANGLE_CUTOFF) }) # Apply general parameters if "general" in params_data: gen = params_data["general"] kwargs.update({ "covalent_cutoff_factor": gen.get("covalent_cutoff_factor", ParametersDefault.COVALENT_CUTOFF_FACTOR), "analysis_mode": gen.get("analysis_mode", ParametersDefault.ANALYSIS_MODE) }) # Create new parameters object and store in session self.session_parameters = AnalysisParameters(**kwargs) def _on_closing(self) -> None: """Handle window closing event. Handles application shutdown, checking for running analysis and prompting user confirmation if needed. :returns: None :rtype: None """ if self.analysis_running: result = messagebox.askyesno( "Confirm Exit", "Analysis is running. Are you sure you want to exit?" ) if not result: return # Stop async executor tae.stop() self.root.destroy()
[docs] def run(self) -> None: """Start the GUI application. Enters the main GUI event loop to begin accepting user interactions. This method blocks until the application is closed. :returns: None :rtype: None """ self.root.mainloop()