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 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