"""Specify calculator parameters."""
import importlib
import tkinter as tk
from tkinter import ttk
from typing import TYPE_CHECKING
from typing import TypeVar
import numpy as np
from autojob.coordinator import job
from autojob.coordinator import validation
from autojob.coordinator.gui import groups
from autojob.coordinator.gui import widgets
if TYPE_CHECKING:
from autojob.coordinator.gui import gui
[docs]
class GroupSelectionCombobox(ttk.Combobox):
"""Select a CalculatorParameterGroup.
Attributes:
parent: The ``ParameterSelectionTab`` in which the
``GroupSelectionCombobox`` is embedded.
var: A :class:`tkinter.StringVar` storing the name of the active
CalculatorParameterGroup.
Note:
The ``.load()`` function of the parent frame is called during the
validation function for the combobox.
"""
def __init__(self, parent: "ParameterSelectionTab"):
"""Initialize a ``GroupSelectionCombobox``.
Args:
parent: The parent ``ParameterSelectionTab`` in which the combobox
resides.
"""
var = tk.StringVar()
values = list(parent._calc_params)
super().__init__(
parent,
state="readonly",
textvariable=var,
validate="focus",
values=values,
)
def display_structure_calc_params(reason: str) -> bool:
if reason != "forced":
self.parent.load()
return True
cmd = self.register(display_structure_calc_params)
self.configure(validatecommand=(cmd, "%V"))
self.parent: ParameterSelectionTab = parent
self.var = var
[docs]
def load(self) -> None:
"""Populate the list of calculator parameters to display."""
self["values"] = list(self.parent.calc_params)
if self["values"]:
if self.current() == -1:
self.current(0)
else:
self.set("")
[docs]
class ParameterPanel(ttk.LabelFrame):
"""A Frame containing GUI elements for defining parameter values.
Attributes:
parent: The ``ParameterSelectionTab`` to which this panel belongs.
param: The parameter whose parametrization is represented by the
panel.
rbf: The ``ParameterRadiobuttonFrame`` used select the input method.
input_section: The ``LabelFrame`` used to enable entering
parametrization values.
lbf: The :class:`~.widgets.ListboxFrame` used to display the parameter
values.
button_frame: The `Frame` in which the add/clear/delete buttons reside.
default_button: The `Button` used to reset the parameter value to its
default value.
check_var: The variable storing the state of ``default_button``.
"""
def __init__(
self, parent: "ParameterSelectionTab", param: job.CalculationParameter
) -> None:
"""Initialize a `ParameterPanel`.
Args:
parent: The ``ParameterSelectionTab`` in which the
``ParameterInputSection`` is embedded.
param: The calculation parameter corresponding to the
``ParameterPanel``.
"""
title = f"{param.name}: {param.description}"
super().__init__(parent, text=title, name=param.name.lower())
self.parent: ParameterSelectionTab = parent
self.param: job.CalculationParameter = param
self.rbf: ParameterRadiobuttonFrame = ParameterRadiobuttonFrame(self)
self.input_section: ParameterInputSection = ParameterInputSection(self)
self.lbf: widgets.ListboxFrame = widgets.ListboxFrame(self)
self.button_frame: ttk.Frame = self.create_button_frame()
self.default_button, self.check_var = self.create_default_button()
self.organize()
self.configure_rbs()
if self.param.default is not None:
self.default_button.invoke()
[docs]
def add_parameter_values(self) -> None:
"""Validates parameter values and adds new values.
Duplicates are removed and the entries in the :class:`tkinter.Listbox`
are sorted.
"""
active_input_method = self.rbf.str_vars[self.rbf.rb_var.get()].get()
# Record validity of entries
validation_results: list[bool] = self._entry_validation(
active_input_method
)
# Format entries according to validity
self._format_entries(active_input_method, validation_results)
if False not in validation_results:
if active_input_method == "from list":
vals = [self.input_section.input_frames[0].var.get()]
elif active_input_method == "by typing":
vals = [self.input_section.input_frames[1].var.get()]
elif active_input_method == "as range":
start = float(self.input_section.input_frames[2].var.get())
end = float(self.input_section.input_frames[3].var.get())
steps = int(self.input_section.input_frames[4].var.get())
vals = list(np.linspace(start, end, steps))
else:
msg = (
f"Active input method ({active_input_method}) "
"not supported."
)
raise ValueError(msg)
active_group: str = self.parent.group_selection_cb.var.get()
self.parent.calc_params[active_group].add_values(self.param, vals)
self.load()
def _entry_validation(self, active_input_method: str) -> list[bool]:
"""Validate the value in a parameter entry widget.
This is a callback for Tkinter validation.
"""
match active_input_method:
case "from list":
param_var = self.input_section.input_frames[0].var
is_valid = [self._validate(param_var)]
case "by typing":
param_var = self.input_section.input_frames[1].var
is_valid = [self._validate(param_var)]
case "as range":
param_vars = [
self.input_section.input_frames[2].var,
self.input_section.input_frames[3].var,
self.input_section.input_frames[4].var,
]
is_valid = self._validate_range(param_vars)
case _:
msg = (
'Invalid "active_input_method. '
'"active_input_method" must be one of: '
'"from list", "by typing", and "as range".'
)
raise ValueError(msg)
return is_valid
def _validate(self, var: tk.StringVar) -> bool:
"""Validates value from ``var.get()``.
Resolves issue with values being stored as strings in ``ttk.Entry``
widgets by attempting to cast the value from ``var.get()`` into the
various allowed types for 'param' and validating the cast value.
Args:
var: A :class:`tkinter.StringVar` object whose value is to be
validated.
Returns:
True if the value obtained by ``var.get()`` is valid. False
otherwise.
"""
return self.param.validate(validation.val_to_native(var.get()))
def _validate_range(self, param_vars: list[tk.StringVar]) -> list[bool]:
"""Validates whether the range specified is valid.
Args:
param_vars: The list of :class:`tkinter.StringVar` s which
represent the start, stop, and steps entries.
Returns:
A list of booleans indicating which entries are valid.
"""
validation_results: list[bool] = [False, False, False]
# Validate start and stop values
validation_results[0] = self._validate(param_vars[0])
validation_results[1] = self._validate(param_vars[1])
# Check that the start value is less than or equal to the stop value
if (
validation_results[0]
and validation_results[1]
and float(param_vars[0].get()) > float(param_vars[1].get())
):
validation_results[0] = False
validation_results[1] = False
# Verify that the number of steps specified is a nonnegative integer
try:
steps = float(param_vars[2].get())
if steps % 1 != 0 and steps < 0:
raise ValueError(
"Number of steps must be a " + "nonnegative integer."
)
validation_results[2] = True
except ValueError:
validation_results[2] = False
return validation_results
# If values are restricted to integers, verify that each value in range
# will be an integer
if float not in self.param.allowed_types:
start = int(param_vars[0].get())
stop = int(param_vars[1].get())
if steps < 2: # noqa: PLR2004
validation_results[2] = True
else:
validation_results[2] = (stop - start) % (steps - 1) == 0
return validation_results
def _format_entries(
self, active_input_method: str, validation_results: list[bool]
) -> None:
"""Format the text in the all ``EntryFrame`` s based on validity.
Args:
active_input_method: A string indicating how the parameter is
being defined. One of ``"from list"``, ``"by typing"``, or
``"as range"``.
validation_results: A list of booleans of length equal to the
number of input frames, which indicates the validity
of the input in the corresponding input frame.
"""
widgs: list[ttk.Combobox | ttk.Entry | tk.Spinbox] = []
if active_input_method == "from list":
widgs.append(self.input_section.input_frames[0].widg)
elif active_input_method == "by typing":
widgs.append(self.input_section.input_frames[1].widg)
elif active_input_method == "as range":
widgs.append(self.input_section.input_frames[2].widg)
widgs.append(self.input_section.input_frames[3].widg)
widgs.append(self.input_section.input_frames[4].widg)
for i, widg in enumerate(widgs):
if validation_results[i]:
colour = "white"
style_prefix = "valid."
else:
colour = "red"
style_prefix = "invalid."
if isinstance(widg, tk.Spinbox):
widg.configure(foreground=colour)
else:
style: ttk.Style = ttk.Style()
style_name: str = style_prefix + widg.winfo_class()
style.configure(style_name, foreground=colour)
widg.configure(style=style_name)
[docs]
def remove_parameter_values(self) -> None:
"""Remove selected parameter values."""
selected = self.lbf.listbox.curselection()
active_group: str = self.parent.group_selection_cb.var.get()
self.parent.calc_params[active_group].remove_values(
self.param, selected
)
self.load()
[docs]
def clear_parameter_values(self) -> None:
"""Remove all parameter values."""
self.lbf.listbox.selection_set(0, self.lbf.listbox.size() - 1)
self.remove_parameter_values()
[docs]
def use_default(self) -> None:
"""Use the default value for the parameter."""
self.clear_parameter_values()
if self.check_var.get() == 1:
statespec = ["disabled"]
state = tk.DISABLED
active_group: str = self.parent.group_selection_cb.var.get()
self.parent.calc_params[active_group].add_values(
self.param, [str(self.param.default)]
)
self.load()
# Deactivate input frames
for frame in self.input_section.input_frames:
try:
frame.widg.state(statespec=statespec)
except AttributeError:
frame.widg.configure(state=state)
else:
statespec = ["!disabled"]
state = tk.NORMAL
self.set_parameter_entry_method()
for rb in self.rbf.rbs:
rb.state(statespec=statespec)
self.lbf.listbox.configure(state=state)
for child in self.button_frame.winfo_children():
child.state(statespec=statespec)
[docs]
def organize(self) -> None:
"""Grid GUI elements."""
self.rbf.grid(column=0, padx=5, pady=5, row=0)
self.input_section.grid(
column=0, columnspan=2, padx=5, pady=5, row=1, rowspan=4
)
self.lbf.grid(
column=3, columnspan=2, row=1, rowspan=4, sticky=tk.N + tk.S
)
self.button_frame.grid(column=2, padx=5, row=3, rowspan=3, sticky=tk.S)
if self.param.default is not None:
self.default_button.grid(
column=4, padx=5, pady=5, row=0, sticky=tk.N + tk.E
)
(cols, _) = self.grid_size()
for i in range(cols):
self.columnconfigure(i, weight=1)
[docs]
def set_parameter_entry_method(self) -> None:
"""Determines the flags for each widget and calls ``self.set_states``.
Raises:
ValueError: If string corresponding to active ``Radiobutton`` is
unexpected.
"""
active_input_method = self.rbf.str_vars[self.rbf.rb_var.get()].get()
if active_input_method == "from list":
self.set_states([1, 0, 0, 0, 0])
elif active_input_method == "by typing":
self.set_states([0, 1, 0, 0, 0])
elif active_input_method == "as range":
self.set_states([0, 0, 1, 1, 1])
else:
msg = (
"The variable 'rb' does not correspond to the"
"value of an expected Radiobutton"
)
raise ValueError(msg)
[docs]
def set_states(self, flags: list[int]) -> None:
"""Sets the states of the parameter entry widgets.
Args:
flags: The indices correspond to the widgets as
follows:
0: Widget with label "select"
1: Widget with label "enter"
2: Widget with label "start"
3: Widget with label "stop"
4: Widget with label "steps"
"""
for i, flag in enumerate(flags):
_Widg = TypeVar(
"_Widg", ttk.Combobox, ttk.Entry, tk.Spinbox, ttk.Frame
)
widg: _Widg = self.input_section.input_frames[i].widg
if flag == 0:
if isinstance(widg, tk.Spinbox):
widg.configure(state=tk.DISABLED)
else:
widg.state(statespec=["disabled"])
elif flag == 1:
if isinstance(widg, tk.Spinbox):
widg.configure(state=tk.NORMAL)
else:
widg.state(statespec=["!disabled"])
[docs]
def load(self) -> None:
"""Load the displayed parameter values."""
group = self.parent.group_selection_cb.var.get()
param_values = [
str(val)
for val in self.parent.calc_params[group].values[self.param]
]
self.lbf.clear_listbox()
for val in param_values:
self.lbf.listbox.insert(tk.END, val)
[docs]
class ParameterSelectionCombobox(ttk.Combobox):
"""A ``Combobox`` to slect a parameter whose values to display.
Args:
parent: The ``ParameterSelectionTab`` to which this `Combobox` belongs.
var: A :class:`tkinter.StringVar` which stores the name of the
parameter whose values are being displayed.
"""
def __init__(self, parent: "ParameterSelectionTab") -> None:
"""Initialize an ``ParameterSelectionCombobox``.
Args:
parent: The ``ParameterSelectionTab`` to which this ``Combobox`` belongs.
"""
values: list[str] = [param.name for param in parent.params]
text_var = tk.StringVar(value=values[0])
super().__init__(
parent,
state="readonly",
textvariable=text_var,
validate="all",
values=values,
)
self.parent: ParameterSelectionTab = parent
self.var = text_var
cmd = self.register(self.change_parameter_panel)
self.configure(validatecommand=cmd)
[docs]
def load(self) -> None:
"""Populate the ``Combobox`` with parameter names."""
values: list[str] = [param.name for param in self.parent.params]
self.configure(values=values)
if values:
if self.current() == -1:
self.current(0)
else:
self.set("")
[docs]
def change_parameter_panel(self) -> bool:
"""Display a new ``ParameterPanel`` for the parameter. Returns True."""
if not self.winfo_viewable():
return True
if self.var.get() == "":
if self.parent.panel_to_display:
self.parent.panel_to_display.pack_forget()
return True
self.parent.panel_to_display.pack_forget()
for panel in self.parent.param_panels:
if panel.param.name == self.var.get():
self.parent.panel_to_display = panel
self.parent.panel_to_display.pack(padx=5, pady=5, side=tk.TOP)
return True
[docs]
class ParameterSelectionTab(ttk.Frame):
"""A GUI element for defining parameter values.
Attributes:
parent: The :class:`tkinter.ttk.Notebook` in which the
``ParameterSelectionTab`` resides.
app: The running :class:`gui.GUI` instance.
group_selection_cb: The :class:`GroupSelectionCombobox` for selecting
the active calculation parameter group.
param_panels: The :class:`ParameterPanel` s for each calculation
parameter.
panel_to_display: The :class:`ParameterPanel` to display.
param_selection_cb: The :class:`tkinter.Combobox` used to select the
active parameter.
"""
def __init__(self, main_app: "gui.GUI") -> None:
"""Initialize a ``ParameterSelectionTab``.
Args:
main_app: The running :class:`gui.GUI` instance.
"""
super().__init__(main_app.notebook)
self.parent: ttk.Notebook = main_app.notebook
self.app: gui.GUI = main_app
self._params: list[job.CalculationParameter] = (
self.load_parameter_list()
)
self._calc_params: dict[str, groups.CalculationParameterGroup] = (
self.load_calc_params()
)
self.group_selection_cb = GroupSelectionCombobox(self)
self.param_panels: list[ParameterPanel] = []
self.panel_to_display: ParameterPanel | None = None
self.param_selection_cb: ParameterSelectionCombobox | None = None
@property
def params(self) -> list[job.CalculationParameter]:
"""Return the calculation parameters."""
return self._params.copy()
@params.setter
def params(self, new_params: list[job.CalculationParameter]):
self._params = new_params
# BUG: Changing calculator after loading tab doesn't change parameter panels
# TODO: Call this upon loading and check if changed
[docs]
def load_parameter_list(self) -> list[job.CalculationParameter]:
"""Return the parameter list based on the selected calculator."""
calc_name = self.app.coordinator.calculator_type
module_name = f"autojob.coordinator.{calc_name.lower()}"
class_name = calc_name.capitalize() + "Job"
module = importlib.import_module(module_name)
job_type: job.Job = getattr(module, class_name)
params = job_type.input_parameters()
return params
[docs]
def load_panels(self) -> None:
"""Load the parameter panels."""
if self.group_selection_cb.var.get() != "":
if self.param_panels:
for panel in self.param_panels:
panel.load()
else:
self.param_panels = self.create_param_panels()
self.panel_to_display: ParameterPanel = self.param_panels[0]
elif self.param_panels:
for panel in self.param_panels:
panel.destroy()
self.param_panels = []
self.panel_to_display = None
@property
def calc_params(
self,
) -> dict[str, groups.CalculationParameterGroup]:
"""A map from parameter group names to the parameter group."""
return self._calc_params.copy()
@calc_params.setter
def calc_params(
self,
new_calc_params: dict[str, groups.CalculationParameterGroup],
):
self._calc_params = new_calc_params
self.group_selection_cb.load()
[docs]
def load_calc_params(
self,
) -> dict[str, groups.CalculationParameterGroup]:
"""Updates or creates calculation parameters.
Returns:
A dictionary mapping the names of
:class:`.groups.CalculationParameterGroup` s to the groups
themselves.
"""
new_calc_params = {}
current_s_groups = list(self.app.coordinator.structure_groups)
# Populate 'new_calc_params' with old values
if hasattr(self, "_calc_params"):
old_calc_params = self.calc_params
for group in old_calc_params:
if group in current_s_groups:
new_calc_params[group] = old_calc_params[group]
for group in current_s_groups:
if group not in list(new_calc_params):
new_calc_params[group] = groups.CalculationParameterGroup(
self.params
)
return new_calc_params
[docs]
def create_param_panels(
self,
) -> list[ParameterPanel]:
"""The ``ParameterPanel`` s of the ``ParameterSelectionTab``."""
param_panels: list[ParameterPanel] = []
# Create parameter panels
for param in self.params:
param_panels.append(ParameterPanel(self, param))
return param_panels
[docs]
def organize(self) -> None:
"""Pack the GUI elements."""
self.group_selection_cb.pack(padx=5, pady=5, side=tk.TOP)
if self.group_selection_cb.var.get() != "":
self.param_selection_cb.pack(padx=5, pady=5, side=tk.TOP)
else:
self.param_selection_cb.pack_forget()
self.param_selection_cb.current(0)
if self.panel_to_display is not None:
self.panel_to_display.pack(padx=5, pady=5, side=tk.TOP)
[docs]
def load(self) -> None:
"""Load the GUI elements."""
self._params = self.load_parameter_list()
self._calc_params = self.load_calc_params()
self.group_selection_cb.load()
if self.param_selection_cb is None:
self.param_selection_cb = ParameterSelectionCombobox(self)
else:
self.param_selection_cb.load()
self.load_panels()
self.organize()