Source code for HighlighterRegion

import os
import re

import tkinter as tk
from tkinter import ttk
from gui.CreateTooltip import CreateTooltip

ELEMENTS = { #dictionary containing the atomic symbol of all 118 elements in the periodic table
    "H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne", "Na", "Mg", "Al", "Si", "P", "S", "Cl", "Ar",
    "K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu", "Zn", "Ga", "Ge", "As", "Se", "Br", "Kr",
    "Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd", "In", "Sn", "Sb", "Te", "I", "Xe",
    "Cs", "Ba", "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd", "Tb", "Dy", "Ho", "Er", "Tm", "Yb", "Lu",
    "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", "Tl", "Pb", "Bi", "Po", "At", "Rn",
    "Fr", "Ra", "Ac", "Th", "Pa", "U", "Np", "Pu", "Am", "Cm", "Bk", "Cf", "Es", "Fm", "Md", "No", "Lr",
    "Rf", "Db", "Sg", "Bh", "Hs", "Mt", "Ds", "Rg", "Cn", "Nh", "Fl", "Mc", "Lv", "Ts", "Og"
}

BONDS = {'_', '-', '=', '%', '#'}

[docs] class HighlighterRegion(object): def __init__(self, parent): """ Initializes the highlighter region and its widgets. Parameters: parent (tk.Widget): The parent widget to attach the highlighter frame to. """ self.initialize_variables() self.setup_frame(parent) self.add_widgets() self.position_widgets()
[docs] def initialize_variables(self): """ Initializes the variables for atom and bond highlighting. """ self.var_forcedBondList = tk.StringVar() self.var_hlAtomList = tk.StringVar() self.var_hlBondList = tk.StringVar() self.var_forceBonds = tk.BooleanVar(value=False) self.var_highlightAtoms = tk.BooleanVar(value=False) self.var_highlightBonds = tk.BooleanVar(value=False) self.var_customThreshold = tk.BooleanVar(value=False) # to be able to add or remove customizable bond thresolds self.elements_list = sorted(ELEMENTS) self.bond_orders = [0, 1, 2, 3] self.threshold_rows = []
[docs] def clear_variables(self): """ Clears the atom and bond highlighting variables (reset to defaults). """ self.var_forcedBondList.set("") self.var_hlAtomList.set("") self.var_hlBondList.set("") self.var_forceBonds.set(False) self.var_highlightAtoms.set(False) self.var_highlightBonds.set(False) self.var_customThreshold.set(False)
[docs] def reset_highlighter_options(self): """ Resets the highlighter options (e.g., disables widgets and clears input lists). """ self.var_highlightAtoms.set(False) self.var_highlightBonds.set(False) self.lbl_highlightedAtoms['state'] = tk.DISABLED self.ent_hlAtomList['state'] = tk.DISABLED self.var_hlAtomList.set("") self.lbl_highlightedBonds['state'] = tk.DISABLED self.ent_hlBondList['state'] = tk.DISABLED self.var_hlBondList.set("")
[docs] def setup_frame(self, parent): """ Sets up the frame for the highlighter region. Parameters: parent (tk.Widget): The parent widget to attach the frame to. """ self.frame = tk.LabelFrame(master=parent, padx=5, text="Customize atoms and bonds", fg="blue", bg="#e0e0e0", relief=tk.GROOVE, borderwidth=2)
[docs] def add_widgets(self): """ Adds widgets (checkboxes, labels, and entry fields) for atom and bond highlighting. """ #----------------------------------------------------------------------------- self.chk_forcedBonds = tk.Checkbutton(master=self.frame, text="force bonds", bg="#e0e0e0", fg='black', variable=self.var_forceBonds, command=self.toggleBondForcer) CreateTooltip(self.chk_forcedBonds, "Check if you want to force a specific bond type between two atoms") self.lbl_forcedBondsList = tk.Label(text="Forced bonds list", master=self.frame, state=tk.DISABLED, bg="#e0e0e0", fg='black') CreateTooltip(self.lbl_forcedBondsList, "Bond orders: 0.5:'_', 1:'-', 1.5:'%', 2:'=', 3:'#'") self.ent_forcedBondsList = tk.Entry(width=30, master=self.frame, bg="#e0e0e0", fg='black', textvariable=self.var_forcedBondList, state=tk.DISABLED) self.ent_forcedBondsList.bind("<FocusOut>", lambda event: self.on_validate_bond_entry(event, self.var_forcedBondList, self.ent_forcedBondsList)) self.ent_forcedBondsList.bind("<Return>", lambda event: self.on_validate_bond_entry(event, self.var_forcedBondList, self.ent_forcedBondsList)) self.ent_forcedBondsList.bind("<Button-1>", lambda event: self.on_enable_editing(event, self.ent_forcedBondsList, self.var_forceBonds)) CreateTooltip(self.ent_forcedBondsList, "Separate each bond by a semicolon. E.g. C01-C02; C02=O03; C04#C05; etc") #----------------------------------------------------------------------------- self.chk_highlightAtoms = tk.Checkbutton(master=self.frame, text="highlight atoms", bg="#e0e0e0", fg='black', variable=self.var_highlightAtoms, command=self.toggleAtomHighlighter) CreateTooltip(self.chk_highlightAtoms, "Check if you want to highlight one or more atoms in your 3D structure") self.lbl_highlightedAtoms = tk.Label(text="Atom list", master=self.frame, state=tk.DISABLED, bg="#e0e0e0", fg='black') CreateTooltip(self.lbl_highlightedAtoms, "List of atoms to highlight in the resulting 3D model") self.ent_hlAtomList = tk.Entry(width=30, master=self.frame, bg="#e0e0e0", fg='black', textvariable=self.var_hlAtomList, state=tk.DISABLED) self.ent_hlAtomList.bind("<FocusOut>", self.on_validate_atom_list) self.ent_hlAtomList.bind("<Return>", self.on_validate_atom_list) self.ent_hlAtomList.bind("<Button-1>", lambda event: self.on_enable_editing(event, self.ent_hlAtomList, self.var_highlightAtoms)) CreateTooltip(self.ent_hlAtomList, "Separate each atom by a comma. E.g. C01, H02, H03, etc") #----------------------------------------------------------------------------- self.chk_highlightBonds = tk.Checkbutton(master=self.frame, text="highlight bonds", bg="#e0e0e0", fg='black', variable=self.var_highlightBonds, command=self.toggleBondHighlighter) CreateTooltip(self.chk_highlightBonds, "Check if you want to highlight one or more bonds in your 3D structure") self.lbl_highlightedBonds = tk.Label(text="Bonds list", master=self.frame, state=tk.DISABLED, bg="#e0e0e0", fg='black') CreateTooltip(self.lbl_highlightedBonds, "List of bonds to highlight: '-' singe, '=' double, '#' triple, '%' aromatic") self.ent_hlBondList = tk.Entry(width=30, master=self.frame, bg="#e0e0e0", fg='black', textvariable=self.var_hlBondList, state=tk.DISABLED) self.ent_hlBondList.bind("<FocusOut>", lambda event: self.on_validate_bond_entry(event, self.var_hlBondList, self.ent_hlBondList)) self.ent_hlBondList.bind("<Return>", lambda event: self.on_validate_bond_entry(event, self.var_hlBondList, self.ent_hlBondList)) self.ent_hlBondList.bind("<Button-1>", lambda event: self.on_enable_editing(event, self.ent_hlBondList, self.var_highlightBonds)) CreateTooltip(self.ent_hlBondList, "Separate each bond by a semicolon. E.g. C01-C02; C03=C04; C01#C09; O08%C06 etc") #----------------------------------------------------------------------------- self.chk_customizeBondThreshold = tk.Checkbutton(master=self.frame, text="custom threshold", bg="#e0e0e0", fg='black', variable=self.var_customThreshold, command=self.toggleCustomThreshold) CreateTooltip(self.chk_customizeBondThreshold, "Check if you want to customize a bond pair, If the distance is smaller than the threshold, then the atom pair will have the specified bond order") self.lbl_customThreshold = tk.Label(text="Custom threshold") self.btn_addCustomThreshold = tk.Button(text="add", master=self.frame, command=self.addThreshold, state=tk.DISABLED) CreateTooltip(self.btn_addCustomThreshold, "Click here to add another atom pair whose minimum distance counts as a bond") self.btn_removeCustomThreshold = tk.Button(master=self.frame, text="remove", command=self.removeThreshold, state=tk.DISABLED) CreateTooltip(self.btn_removeCustomThreshold, "Click here to remove the last atom pair bond threshold") self.threshold_container = tk.Frame(master=self.frame, bg="#e0e0e0")
[docs] def position_widgets(self): """ Positions the widgets inside the frame using grid layout. """ self.chk_forcedBonds.grid(row=0) self.lbl_forcedBondsList.grid(row=1, column=0) self.ent_forcedBondsList.grid(row=1, column=1) self.chk_highlightAtoms.grid(row=2, column=0) self.lbl_highlightedAtoms.grid(row=3, column=0) self.ent_hlAtomList.grid(row=3, column=1) self.chk_highlightBonds.grid(row=4, column=0) self.lbl_highlightedBonds.grid(row=5, column=0) self.ent_hlBondList.grid(row=5, column=1) self.chk_customizeBondThreshold.grid(row=6) self.btn_addCustomThreshold.grid(row=7, column=0) self.btn_removeCustomThreshold.grid(row=7, column=1) self.threshold_container.grid(row=8, column=0, columnspan=2, sticky="w")
[docs] def toggleBondForcer(self): """ Toggles the possibility of forcing two atoms to be bonded in a specific way. Only works when there is only one input file. If the checkbox is checked, the bond force list entry is enabled. If unchecked, it is disabled and cleared. """ if self.var_forceBonds.get() == True: print("Please specify which bonds to force separated by semicolons") self.lbl_forcedBondsList['state'] = tk.NORMAL self.ent_forcedBondsList['state'] = tk.NORMAL else: print("List of atoms removed") self.lbl_forcedBondsList['state'] = tk.DISABLED self.ent_forcedBondsList['state'] = tk.DISABLED self.var_forcedBondList.set("")
[docs] def toggleAtomHighlighter(self): """ Toggles the state of atom highlighting. Enables or disables the atom list entry. If the checkbox is checked, the atom list entry is enabled. If unchecked, it is disabled and cleared. """ if self.var_highlightAtoms.get() == True: print("Please select the atoms to highlight separated by commas") self.lbl_highlightedAtoms['state'] = tk.NORMAL self.ent_hlAtomList['state'] = tk.NORMAL else: print("List of atoms removed") self.lbl_highlightedAtoms['state'] = tk.DISABLED self.ent_hlAtomList['state'] = tk.DISABLED self.var_hlAtomList.set("")
[docs] def toggleBondHighlighter(self): """ Toggles the state of bond highlighting. Enables or disables the bond list entry. If the checkbox is checked, the bond list entry is enabled. If unchecked, it is disabled and cleared. """ if self.var_highlightBonds.get() == True: print("Please select the atoms to highlight separated by semicolons") self.lbl_highlightedBonds['state'] = tk.NORMAL self.ent_hlBondList['state'] = tk.NORMAL else: print("List of atoms removed") self.lbl_highlightedBonds['state'] = tk.DISABLED self.ent_hlBondList['state'] = tk.DISABLED self.var_hlBondList.set("")
[docs] def toggleCustomThreshold(self): """ Toggles the ability to customize the threshold between two atoms and make it a type of bond. """ if self.var_customThreshold.get() == True: self.btn_removeCustomThreshold['state'] = tk.NORMAL self.btn_addCustomThreshold['state'] = tk.NORMAL else: self.btn_removeCustomThreshold['state'] = tk.DISABLED self.btn_addCustomThreshold['state'] = tk.DISABLED while self.threshold_rows: #remove any custom threshold rows there are (if any) self.removeThreshold()
[docs] def addThreshold(self): """ Create a new row with read-only dropdowns for Atom 1, Atom 2, Bond order, and an entry for the numeric threshold. """ if not self.var_customThreshold.get(): print("Enable 'custom threshold' first to add rules.") return row_index = len(self.threshold_rows) row_frame = tk.Frame(master=self.threshold_container, bg="#e0e0e0") # Variables var_a1 = tk.StringVar(value="C") var_a2 = tk.StringVar(value="C") var_order = tk.StringVar(value="1") # use string for ttk.Combobox consistency var_thr = tk.StringVar(value="") # numeric entry as string # Labels lbl_a1 = tk.Label(row_frame, text="Atom 1", bg="#e0e0e0", fg='black') lbl_a2 = tk.Label(row_frame, text="Atom 2", bg="#e0e0e0", fg='black') lbl_order = tk.Label(row_frame, text="Bond order", bg="#e0e0e0", fg='black') lbl_thr = tk.Label(row_frame, text="Threshold (Å)", bg="#e0e0e0", fg='black') # Read-only dropdowns (Combobox) cb_a1 = ttk.Combobox(row_frame, textvariable=var_a1, values=self.elements_list, width=6, state="readonly") cb_a2 = ttk.Combobox(row_frame, textvariable=var_a2, values=self.elements_list, width=6, state="readonly") cb_order = ttk.Combobox(row_frame, textvariable=var_order, values=[str(x) for x in self.bond_orders], width=3, state="readonly") # Threshold as entry (user types a float) ent_thr = tk.Entry(row_frame, width=8, bg="#e0e0e0", fg='black', textvariable=var_thr) # Tooltips CreateTooltip(cb_a1, "Select the first element symbol") CreateTooltip(cb_a2, "Select the second element symbol") CreateTooltip(cb_order, "Bond order to assign when the distance is below the threshold") CreateTooltip(ent_thr, "Enter a positive distance threshold (Å). Example: 1.54") # Layout lbl_a1.grid(row=0, column=0, padx=(2, 2), pady=2, sticky="w") cb_a1.grid(row=0, column=1, padx=(2, 8), pady=2, sticky="w") lbl_a2.grid(row=0, column=2, padx=(2, 2), pady=2, sticky="w") cb_a2.grid(row=0, column=3, padx=(2, 8), pady=2, sticky="w") lbl_order.grid(row=0, column=4, padx=(2, 2), pady=2, sticky="w") cb_order.grid(row=0, column=5, padx=(2, 8), pady=2, sticky="w") lbl_thr.grid(row=0, column=6, padx=(2, 2), pady=2, sticky="w") ent_thr.grid(row=0, column=7, padx=(2, 2), pady=2, sticky="w") # Validation: threshold must be positive float on focus-out / Enter def _on_thr_validate(event=None): txt = var_thr.get().strip() if txt == "": return # allow empty while editing try: val = float(txt) if val <= 0: raise ValueError except ValueError: print(f"Invalid threshold '{txt}'. Enter a positive number (e.g., 1.54).") var_thr.set("") ent_thr.focus_set() def _on_order_change(*_): if var_order.get() == "0": ent_thr.config(state="disabled") var_thr.set("") else: ent_thr.config(state="normal") var_order.trace_add("write", _on_order_change) ent_thr.bind("<FocusOut>", _on_thr_validate) ent_thr.bind("<Return>", _on_thr_validate) # Show row row_frame.grid(row=row_index, column=0, sticky="w") # Track row for later enable/disable/remove/retrieve self.threshold_rows.append({ "frame": row_frame, "var_a1": var_a1, "var_a2": var_a2, "var_order": var_order, # string "1" | "2" | "3" "var_thr": var_thr, "widgets": { "lbl_a1": lbl_a1, "cb_a1": cb_a1, "lbl_a2": lbl_a2, "cb_a2": cb_a2, "lbl_order": lbl_order, "cb_order": cb_order, "lbl_thr": lbl_thr, "ent_thr": ent_thr } }) print("Added custom threshold row.")
[docs] def removeThreshold(self): """ Remove the most recently added custom threshold row (if any). """ if not self.threshold_rows: print("No custom threshold rows to remove.") return row = self.threshold_rows.pop() try: # Destroy the widgets/frame for that row row["frame"].destroy() except Exception: pass print("Removed last custom threshold row.")
[docs] def get_custom_thresholds(self): """ Returns a list of dicts: { "atom_pair": ("Atom1Symbol", "Atom2Symbol"), # canonicalized (sorted) "bond_order": int, # 1, 2, or 3 "threshold": float # Å } Skips incomplete/invalid rows. """ result = [] for row in self.threshold_rows: a1 = row["var_a1"].get().strip() a2 = row["var_a2"].get().strip() order_txt = row["var_order"].get().strip() thr_txt = row["var_thr"].get().strip() if not a1 or not a2 or not order_txt: continue try: order = int(order_txt) if order not in (0, 1, 2, 3): continue if order == 0: thr = 999 else: thr = float(thr_txt) if thr <= 0: continue except ValueError: continue pair = tuple(sorted((a1, a2))) result.append({"atom_pair": pair, "bond_order": order, "threshold": thr}) return result
[docs] def check_for_atom_syntax(self, entry: str) -> bool: """ Checks if the atom entry follows the correct syntax: ElementSymbol + two-digit number. Parameters: entry (str): The atom entry to validate (e.g., "C01", "H02"). Returns: bool: True if the entry is valid, False otherwise. """ # Use regex to find where letters end and digits begin match = re.match(r"([A-Z][a-z]?)(\d+)$", entry) # Ensures first letter is uppercase, second is optional lowercase if not match: print(f"Invalid format: '{entry}'. Expected ElementSymbol (1-2 letters) + two-digit number.") return False element_symbol, number_part = match.groups() # Validate atomic symbol if element_symbol not in ELEMENTS: print(f"Invalid element symbol '{element_symbol}' in '{entry}'.") return False # Validate number part (must be exactly two digits) if not re.fullmatch(r"\d{2}", number_part): print(f"Invalid number format '{number_part}' in '{entry}'. Must be a two-digit number (01-99).") return False return True
[docs] def validate_atom_list(self, entry: str) -> bool: """ Validates a comma-separated list of atom entries. Parameters: entry (str): A comma-separated string of atom entries to validate (e.g., "C01, H02"). Returns: bool: True if all entries are valid, False if any entry is invalid. """ # Step 1: Remove spaces and newlines entry = entry.replace(" ", "").replace("\n", "") # Step 2: Split into a list based on commas atom_entries = entry.split(",") # Step 3: Apply check_for_atom_syntax() to each entry invalid_entries = [atom for atom in atom_entries if not self.check_for_atom_syntax(atom)] # Step 4: Print errors if there are invalid entries if invalid_entries: print(f"Invalid entries found: {', '.join(invalid_entries)}") return False return True
[docs] def on_validate_atom_list(self, event=None): """ Handles validation when the atom entry loses focus or the Enter key is pressed. Parameters: event (tk.Event, optional): The event that triggered the validation. Default is None. Returns: str | None: Returns "break" if Enter was pressed and input is invalid, otherwise None. """ self.event = event user_input = self.var_hlAtomList.get().strip() if self.validate_atom_list(user_input): # Valid input: Keep it & confirm entry print(f"Valid input: {user_input}") # Confirmation message self.ent_hlAtomList.config(state=tk.DISABLED) # Disable further editing return True # Allow normal event propagation else: # Invalid input: Clear entry print("Invalid input! Clearing entry box.") self.var_hlAtomList.set("") # Clears the text box self.ent_hlAtomList.config(state=tk.NORMAL) # Allow further editing # If Enter was pressed, prevent unintended newline behavior if event and event.keysym == "Return": return "break" return None # Default return
[docs] def validate_bond_list(self, entry: str) -> bool: """ Validates a semicolon-separated list of bond entries. Parameters: entry (str): A semicolon-separated string of bond entries to validate (e.g., "C01-C02; C03=C04"). Returns: bool: True if all bond entries are valid, False if any entry is invalid. """ # Remove spaces and newlines entry = entry.replace(" ", "").replace("\n", "") # Split into a list based on semicolons bond_entries = entry.split(";") # Apply check_for_bond_syntax() to each entry invalid_entries = [bond for bond in bond_entries if not self.check_for_bond_syntax(bond)] # Print errors if there are invalid entries if invalid_entries: print(f"Invalid entries found: {', '.join(invalid_entries)}") return False return True
[docs] def on_validate_bond_entry(self, event=None, var=None, entry_widget=None): """ Handler for validating bond entries. Parameters: event (tk.Event, optional): The event that triggered the validation. var (tk.StringVar): The variable linked to the entry widget. entry_widget (tk.Entry): The entry widget to validate. Returns: str | None: Returns "break" if Enter was pressed and input is invalid, otherwise None. """ self.event = event user_input = var.get().strip() if self.validate_bond_list(user_input): print(f"Valid input: {user_input}") entry_widget.config(state=tk.DISABLED) return True else: print("Invalid input! Clearing entry box.") var.set("") # Clear the StringVar entry_widget.config(state=tk.NORMAL) if event and event.keysym == "Return": return "break" return None
[docs] def check_for_bond_syntax(self, entry: str) -> bool: """ Checks if the bond entry follows the correct syntax (e.g., "C01-C02"; "C03=C04"). Parameters: entry (str): The bond entry to validate. Returns: bool: True if the bond entry follows the correct syntax, False otherwise. """ # Define the bond separators separators = ["_","-", "=", "#", "%"] # Check if the entry contains exactly one separator if sum(entry.count(sep) for sep in separators) != 1: print(f"Invalid format: '{entry}'. Must contain exactly one of the following separators: {', '.join(separators)}.") return False # Split the entry into two parts based on the separator for sep in separators: if sep in entry: left_part, right_part = entry.split(sep) break # Validate the left and right parts using check_for_atom_syntax() if not self.check_for_atom_syntax(left_part): print(f"Invalid left part '{left_part}' in '{entry}'.") return False if not self.check_for_atom_syntax(right_part): print(f"Invalid right part '{right_part}' in '{entry}'.") return False return True
[docs] def on_enable_editing(self, event, tk_textbox, tk_checkbox_variable): """ Enables editing for the clicked entry box if the associated checkbox is checked. Parameters: event (tk.Event): The event that triggered this function. tk_textbox (tk.Entry): The text entry widget to enable or disable. tk_checkbox_variable (tk.BooleanVar): The associated checkbox's variable that determines the state. """ self.event = event if tk_checkbox_variable.get() == True: tk_textbox.config(state=tk.NORMAL) # Enable the specific textbox that was clicked tk_textbox.select_range(0, tk.END) # Optionally select the entire text