Source code for ccu.fancyplots._gui.energy
"""GUI elements for defining free energies in a mechanism.
This module defines the :class:`EnergyWindow` class.
"""
import logging
import tkinter as tk
from tkinter import messagebox
from tkinter import ttk
from typing import TYPE_CHECKING
from ccu.fancyplots._gui.menu import show_edit_menu
if TYPE_CHECKING:
from ccu.fancyplots._gui.mechanism import MechanismSection
logger = logging.getLogger(__name__)
[docs]
class EnergyWindow(tk.Toplevel):
"""Define free energies for mechanism paths.
Attributes:
dropdown: A ``tk.OptionMenu`` for selecting the pathway to configure.
energy_definition_frame: A ``ttk.Frame`` containing ``ttk.Entry``
and ``ttk.Label`` instances for defining free energies.
free_energy_entries: A dictionary mapping mechanism step names to the
:class:`.ttk.Entry` widget.
free_energy_labels: A dictionary mapping mechanism step names to the
labeling widget.
free_energy_vars: A dictionary mapping mechanism step names to the
:class:`tkinter.StringVar` instances in control of the
:class:`.ttk.Entry` widgets which record the free energies of
the mechanism step.
legend_entry: The ``ttk.Entry`` for defining the legend text for the
current pathway.
legend_label: The ``ttk.Label`` for defining the legend text for the
current pathway.
parent: A :class:`~ccu.fancyplots._gui.mechanism.MechanismSection`
path_var: A ``tk.StringVar`` recording the name of the pathway for
which energies are being defined.
"""
def __init__(self, parent: "MechanismSection", *args, **kwargs) -> None:
"""Create and launch a window for specifying free energies.
Args:
parent: The parent :class:`.mechanism.MechanismSection`.
*args: Positional arguments for :class:`tkinter.Toplevel`.
**kwargs: Keyword arguments fo :class:`tkinter.Toplevel`.
"""
super().__init__(parent, *args, **kwargs)
self.parent = parent
height = 160 + (15 * (len(self.parent.mechanism) // 2))
self.geometry(f"400x{height}")
self.title("Fancy Plots - Energy Declaration")
self.path_var, self.dropdown = self._make_dropdown()
self.free_energy_entries: dict[str, ttk.Entry] = {}
self.free_energy_labels: dict[str, ttk.Label] = {}
self.free_energy_vars: dict[str, tk.StringVar] = {}
self.energy_definition_frame = self._create_free_energy_widgets()
self.legend_label, self.legend_entry, self.legend_var = (
self._create_legend_widgets()
)
# HACK: solution to being unable to pass the previous value
# The the OptionMenu callback
# This is only ever modified in EnergyWindow.update_widgets
self._previous_pathway = self.pathway
self._organize()
self.protocol(
"WM_DELETE_WINDOW",
self.quit_window,
)
self._configure_key_bindings()
to_take_focus = next(iter(self.free_energy_entries.values()))
to_take_focus.focus_set()
[docs]
def _make_dropdown(self) -> tuple[tk.StringVar, tk.OptionMenu]:
"""Create a dropdown menu for selecting reaction pathways.
Returns:
A 2-tuple (``var``, ``menu``) representing the
:class:`tkinter.StringVar` storing the value of the
active reation pathway and the option menu.
"""
var = tk.StringVar(self, self.options[0])
dropdown = tk.OptionMenu(
self,
var,
*self.options,
command=self.update_widgets,
)
return var, dropdown
[docs]
def _organize_free_energy_widgets(self) -> None:
"""Organize the free energy widgets into an x-by-2 grid."""
for i, (label, entry) in enumerate(
zip(
self.free_energy_labels.values(),
self.free_energy_entries.values(),
strict=True,
)
):
j = 2 * i
row = (j // 4) + 1
column = j % 4
label.grid(row=row, column=column)
entry.grid(row=row, column=column + 1)
[docs]
def _create_free_energy_widgets(
self,
) -> ttk.Frame:
"""Create widgets for specifying the free energies of each path.
This method modifies
:attr:`ccu.fancyplots._gui.energy.EnergyWindow.free_energy_labels` and
:attr:`ccu.fancyplots._gui.energy.EnergyWindow.free_energy_entries` in
place.
Returns:
The :class:`.ttk.Frame` in which the free energy widgets reside.
"""
energies = self.parent.diagram_data["energy_data"][self.pathway_index]
entry_frame = ttk.Frame(self)
def _validate_float(name: str) -> bool:
w = self.nametowidget(name)
try:
# Allow empty strings
val = float(w.get()) if w.get() else ""
w.configure(style="Valid.Fancy.TEntry")
self.save_energy_data()
except ValueError:
val = None
return val is not None
def _invalid_float(name: str) -> None:
w: ttk.Entry = self.nametowidget(name)
w.configure(style="Invalid.Fancy.TEntry")
msg = f"'{w.get()}' is neither a number nor an empty string."
logger.warning(msg)
messagebox.showwarning("Number not recognized!", message=msg)
self.lift()
self.after(1, lambda: self.focus_force())
validate_command = (self.register(_validate_float), "%W")
invalid_command = (self.register(_invalid_float), "%W")
for i, step in enumerate(self.parent.mechanism):
label = ttk.Label(entry_frame, text=step)
self.free_energy_labels[step] = label
value = "" if energies[i] is None else str(float(energies[i]))
var = tk.StringVar(value=value)
entry = ttk.Entry(
entry_frame,
width=12,
validate="focusout",
validatecommand=validate_command,
invalidcommand=invalid_command,
textvariable=var,
)
self.free_energy_entries[step] = entry
self.free_energy_vars[step] = var
self._organize_free_energy_widgets()
return entry_frame
[docs]
def _create_legend_widgets(
self,
) -> tuple[ttk.Label, ttk.Entry, tk.StringVar]:
"""Create the widgets for specifying legend labels.
Returns:
A 2-tuple (``label``, ``entry``) whose first and second elements
are a :class:`.ttk.Label` and :class:`.ttk.Entry`, respectively.
"""
legend_labels = self.parent.diagram_data["legend_labels"]
legend_label = legend_labels[self.pathway_index]
label = ttk.Label(self, text="Path Label \n (Legend)")
var = tk.StringVar(value=legend_label)
def _validate_all(name: str) -> bool:
w = self.nametowidget(name)
try:
# Allow empty strings
val = float(w.get()) if w.get() else ""
w.configure(style="Valid.Fancy.TEntry")
self.save_legend_data()
except ValueError:
val = None
return val is not None
command = (self.register(_validate_all), "%W")
entry = ttk.Entry(
self,
width=25,
textvariable=var,
validate="focusout",
validatecommand=command,
)
return label, entry, var
[docs]
def _organize(self) -> None:
self.dropdown.grid(row=1, column=1, columnspan=10, sticky=tk.W)
self.energy_definition_frame.grid(row=2, column=1, columnspan=100)
self.legend_label.grid(row=3, column=1, sticky=tk.W)
self.legend_entry.grid(row=3, column=2, sticky=tk.W)
[docs]
def _configure_key_bindings(self) -> None:
"""Configure key bindings for the widget."""
self.bind_class(
"Entry",
"<Button-3><ButtonRelease-3>",
show_edit_menu(self),
)
self.bind_class(
"Entry",
"<Control-q>",
self.parent.parent._select_all,
)
@property
def options(self) -> list[str]:
"""The options (pathways) for the option menu."""
options = []
for i in range(self.parent.path_panel.npaths):
options.append(f"Pathway {i + 1}")
return options
@property
def pathway(self):
"""The pathway indicated by the dropdown selection."""
return self.path_var.get()
@property
def pathway_index(self):
"""The index of pathway indicated by the dropdown."""
return self.options.index(self.pathway)
[docs]
def save_energy_data(self, index: int | None = None) -> None:
"""Update FED data with the values from :attr:`EnergyWindow.free_energy_entries`.
Args:
index: The index of the pathway to update with the current widget
values. Defaults to None, in which case,
:attr:`EnergyWindow.pathway_index` is used.
"""
logger.debug("Saving energy data")
index = self.pathway_index if index is None else index
feddata = self.parent.diagram_data
energies = feddata["energy_data"][index]
for i, entry in enumerate(self.free_energy_entries.values()):
try:
energies[i] = float(entry.get())
except ValueError:
msg = f"No value provided for step {i}. Setting to None."
logger.info(msg)
energies[i] = None
[docs]
def save_legend_data(self, index: int | None = None) -> None:
"""Save the legend label for the present pathway and return True.
Args:
index: The index of the pathway to update with the current widget
values. Defaults to None, in which case,
:attr:`EnergyWindow.pathway_index` is used.
"""
logger.debug("Saving legend data")
index = self.pathway_index if index is None else index
feddata = self.parent.diagram_data
label = self.legend_entry.get() or None
feddata["legend_labels"][index] = label
return True
[docs]
def update_widgets(self, _) -> None:
"""Update the energy and legend ``Entry`` widgets.
.. admonition:: Developer's Note
**This should be the only place that**
:attr:`EnergyWindow._previous_pathway` **is edited**. Unlike other
Tkinter widgets whose callbacks allow for the specification of
state-dependent variables (e.g., the name of the triggering
widget, the value before the action, etc.), the OptionMenu
callback is always called without arguments. This makes it
difficult to update the parameters before changing the values
because the value in the option menu is already changed when the
callback is called. A workaround is to duplicate the state of the
shown option in the :attr:`EnergyWindow._previous_pathway`
attribute and manually manage it here.
"""
logger.info(
f"Updating widgets in {self.__module__}.{self.__class__.__name__}"
)
index = self.options.index(self._previous_pathway)
self.save_energy_data(index)
self.save_legend_data(index)
self._previous_pathway = self.pathway
energies = self.parent.diagram_data["energy_data"][self.pathway_index]
for i, var in enumerate(self.free_energy_vars.values()):
value = energies[i]
var.set("" if value is None else value)
legend_labels = self.parent.diagram_data["legend_labels"]
label = legend_labels[self.pathway_index]
self.legend_var.set("" if label is None else label)
[docs]
def quit_window(self) -> None:
"""Gracefully quit the window and save data."""
logger.debug("Quitting energy window")
self.save_energy_data()
self.save_legend_data()
self.parent.parent._quit_window("energy_window", self)()