"""Equation and parameter dialogs for fitting."""
from typing import Any, Dict, List, Optional, Tuple
from tkinter import (
Toplevel,
StringVar,
IntVar,
BooleanVar,
Spinbox,
Text,
ttk,
)
from config import (
EQUATIONS,
EXIT_SIGNAL,
UI_STYLE,
SPINBOX_STYLE,
apply_hover_to_children,
get_entry_font,
)
from i18n import t
from utils import parse_optional_float
from frontend.keyboard_nav import bind_enter_to_accept, setup_arrow_enter_navigation
from frontend.ui_dialogs.tooltip import bind_tooltip
from frontend.window_utils import place_window_centered
UNICODE_PARAM_MAP: Dict[str, str] = {
r"\u03B1": "Îą",
r"\u03B2": "β",
r"\u03B3": "Îł",
r"\u03B4": "δ",
r"\u03B5": "Îľ",
r"\u03B6": "Îś",
r"\u03B7": "Ρ",
r"\u03B8": "θ",
r"\u03BB": "Îť",
r"\u03BC": "Îź",
r"\u03BE": "Ξ",
r"\u03C0": "Ď",
r"\u03C1": "Ď",
r"\u03C3": "Ď",
r"\u03C6": "Ď",
r"\u03C9": "Ď",
r"\u0394": "Î",
r"\u03A3": "ÎŁ",
r"\u03A6": "ÎŚ",
r"\u03A9": "Ί",
}
# Shared hint text for parameter/formula dialogs (Greek Unicode codes + exit instruction)
_UNICODE_HINT_LINES: str = (
r"\u03B1=ι, \u03B2=β, \u03B3=γ, \u03B4=δ, \u03B5=ξ" + "\n"
r"\u03B6=Μ, \u03B7=Ρ, \u03B8=θ, \u03BB=Ν, \u03BC=Ο" + "\n"
r"\u03BE=Ξ, \u03C0=Ď, \u03C1=Ď, \u03C3=Ď, \u03C6=Ď" + "\n"
r"\u03C9=Ď, \u0394=Î, \u03A3=ÎŁ, \u03A6=ÎŚ, \u03A9=Ί"
)
def _equation_label(eq_id: str) -> str:
"""Return translated equation name with human-readable fallback."""
key = f"equations.{eq_id}"
translated = t(key)
if translated == key:
return eq_id.replace("_", " ").title()
return translated
def _equation_description(eq_id: str) -> str:
"""Return translated equation description (empty when missing)."""
key = f"equations_descriptions.{eq_id}"
translated = t(key)
return "" if translated == key else translated
def _normalize_unicode_text(text: str) -> str:
"""
Replace explicit Unicode escape sequences with their corresponding characters.
Converts Unicode escape sequences like '\\u03B1' to their corresponding
Greek letters (e.g., 'Îą') in arbitrary text (names, formulas, etc.).
Args:
text: Input text containing Unicode escape sequences (e.g., ``'\\u03B1'``).
Returns:
Text with escape sequences replaced by their corresponding characters
(e.g., ``'Îą'``).
"""
for code, char in UNICODE_PARAM_MAP.items():
text = text.replace(code, char)
return text
def _normalize_param_name(name: str) -> str:
"""
Normalize parameter names by replacing Unicode escape sequences.
Replaces explicit Unicode escape sequences like '\\u03B1' with their
corresponding Greek letters and strips whitespace.
Args:
name: Parameter name string that may contain Unicode escape sequences
(e.g., ``'\\u03B1'``).
Returns:
Normalized parameter name with escape sequences replaced (e.g., ``'Îą'``)
and whitespace removed.
"""
return _normalize_unicode_text(name.strip())
[docs]
def ask_equation_type(
parent_window: Any,
) -> Tuple[
str,
Optional[List[Optional[float]]],
Optional[Tuple[List[Optional[float]], List[Optional[float]]]],
]:
"""
Dialog to select fitting equation type.
Optionally allows configuring initial values and bounds per parameter.
Displays a grid of buttons for predefined equation types, plus options
for custom equations and exiting.
Args:
parent_window: Parent Tkinter window.
Returns:
Tuple of ``(equation_type, user_initial_guess, user_bounds)``.
``user_initial_guess`` and ``user_bounds`` are ``None`` when not configured.
"""
from fitting import get_equation_param_info
equation_level = Toplevel()
equation_level.title(t("dialog.equation_type"))
equation_level.selected_equation = ""
equation_level.user_initial_guess: Optional[List[Optional[float]]] = None
equation_level.user_bounds: Optional[
Tuple[List[Optional[float]], List[Optional[float]]]
] = None
def _on_close_equation_type() -> None:
equation_level.selected_equation = EXIT_SIGNAL
equation_level.destroy()
equation_level.protocol("WM_DELETE_WINDOW", _on_close_equation_type)
equation_level.resizable(False, False)
equation_level.frame_custom = ttk.Frame(
equation_level, padding=UI_STYLE["border_width"]
)
equation_level.message = ttk.Label(
equation_level.frame_custom,
text=t("dialog.select_equation"),
style="LargeBold.TLabel",
)
configure_params_var: BooleanVar = BooleanVar(value=False)
equation_level.configure_params_cb = ttk.Checkbutton(
equation_level.frame_custom,
text=t("dialog.configure_initial_params"),
variable=configure_params_var,
)
equation_keys = list(EQUATIONS.keys())
def _show_param_dialog(eq_type: str) -> None:
param_info = get_equation_param_info(eq_type)
if not param_info:
equation_level.destroy()
return
param_names, formula = param_info
param_dlg = Toplevel(equation_level)
param_dlg.title(t("dialog.param_config_title"))
param_dlg.transient(equation_level)
param_dlg.grab_set()
frm = ttk.Frame(param_dlg, padding=UI_STYLE["padding"])
frm.pack(
padx=UI_STYLE["padding"], pady=UI_STYLE["padding"], fill="both", expand=True
)
ttk.Label(frm, text=f"{t('dialog.equation')} {formula}").grid(
row=0, column=0, columnspan=5, padx=4, pady=(2, 8), sticky="w"
)
ttk.Label(frm, text=t("dialog.param_column_name")).grid(
row=1, column=0, padx=4, pady=2
)
ttk.Label(frm, text=t("dialog.param_column_initial")).grid(
row=1, column=1, padx=4, pady=2
)
ttk.Label(frm, text=t("dialog.param_column_range_start")).grid(
row=1, column=2, padx=4, pady=2
)
ttk.Label(frm, text=t("dialog.param_column_range_end")).grid(
row=1, column=3, padx=4, pady=2
)
initial_entries: List[ttk.Entry] = []
lower_entries: List[ttk.Entry] = []
upper_entries: List[ttk.Entry] = []
for i, pname in enumerate(param_names):
r = i + 2
ttk.Label(frm, text=pname).grid(row=r, column=0, padx=4, pady=2, sticky="w")
e_init = ttk.Entry(frm, width=12, font=get_entry_font())
e_init.grid(row=r, column=1, padx=4, pady=2)
initial_entries.append(e_init)
e_lo = ttk.Entry(frm, width=12, font=get_entry_font())
e_lo.grid(row=r, column=2, padx=4, pady=2)
lower_entries.append(e_lo)
e_hi = ttk.Entry(frm, width=12, font=get_entry_font())
e_hi.grid(row=r, column=3, padx=4, pady=2)
upper_entries.append(e_hi)
def on_accept() -> None:
initial_guess: List[Optional[float]] = [
parse_optional_float(e.get()) for e in initial_entries
]
lower_list: List[Optional[float]] = [
parse_optional_float(e.get()) for e in lower_entries
]
upper_list: List[Optional[float]] = [
parse_optional_float(e.get()) for e in upper_entries
]
equation_level.user_initial_guess = initial_guess
equation_level.user_bounds = (lower_list, upper_list)
param_dlg.destroy()
btn_accept = ttk.Button(
frm,
text=t("dialog.accept"),
command=on_accept,
style="Primary.TButton",
width=UI_STYLE["button_width"],
)
btn_accept.grid(
row=len(param_names) + 2,
column=1,
columnspan=2,
padx=UI_STYLE["padding"],
pady=UI_STYLE["padding"],
)
apply_hover_to_children(frm)
param_dlg.resizable(False, False)
place_window_centered(param_dlg, preserve_size=True)
equation_level.wait_window(param_dlg)
def handle_equation_click(eq_type: str) -> None:
equation_level.selected_equation = eq_type
if not configure_params_var.get():
equation_level.destroy()
return
_show_param_dialog(eq_type)
equation_level.destroy()
def handle_custom_click() -> None:
equation_level.selected_equation = "custom"
equation_level.destroy()
def handle_exit_click() -> None:
equation_level.selected_equation = EXIT_SIGNAL
equation_level.destroy()
for attr_name in equation_keys:
btn_text = _equation_label(attr_name)
desc = _equation_description(attr_name)
formula = EQUATIONS.get(attr_name, {}).get("formula", "")
tooltip_text = f"{desc}\n{t('dialog.equation')} {formula}" if formula else desc
btn = ttk.Button(
equation_level.frame_custom,
text=btn_text,
command=lambda eq_type=attr_name: handle_equation_click(eq_type),
style="Equation.TButton",
width=32,
)
bind_tooltip(btn, tooltip_text)
setattr(equation_level, attr_name, btn)
equation_level.custom = ttk.Button(
equation_level.frame_custom,
text=t("equations.custom_formula"),
command=handle_custom_click,
style="Equation.TButton",
width=32,
)
equation_level.accept_button = ttk.Button(
equation_level.frame_custom,
text=t("dialog.exit_option"),
command=handle_exit_click,
style="Danger.TButton",
width=UI_STYLE["button_width"],
)
equation_level.frame_custom.grid(column=0, row=0)
equation_level.message.grid(
column=0, row=0, columnspan=3, padx=UI_STYLE["padding"], pady=6
)
equation_level.configure_params_cb.grid(
column=0, row=1, columnspan=3, padx=UI_STYLE["padding"], pady=4, sticky="w"
)
_pad = UI_STYLE["padding"]
_start_row = 2
for i, attr_name in enumerate(equation_keys):
getattr(equation_level, attr_name).grid(
column=i % 3, row=_start_row + i // 3, padx=_pad, pady=_pad
)
_last_row = _start_row + (len(equation_keys) + 2) // 3
equation_level.custom.grid(
column=0, row=_last_row, columnspan=3, padx=_pad, pady=_pad
)
equation_level.accept_button.grid(column=2, row=_last_row + 1, padx=_pad, pady=_pad)
# Arrow keys + Enter navigation: 3-column grid of equation buttons, then custom, then accept
eq_buttons = [getattr(equation_level, name) for name in equation_keys]
nav_rows: List[List[Any]] = []
for i in range(0, len(eq_buttons), 3):
row = eq_buttons[i : i + 3] + [None] * (3 - min(3, len(eq_buttons) - i))
nav_rows.append(row[:3])
nav_rows.append([equation_level.custom, None, None])
nav_rows.append([None, None, equation_level.accept_button])
setup_arrow_enter_navigation(nav_rows)
apply_hover_to_children(equation_level.frame_custom)
if equation_keys:
getattr(equation_level, equation_keys[0]).focus_set()
place_window_centered(equation_level, preserve_size=True)
parent_window.wait_window(equation_level)
return (
equation_level.selected_equation,
getattr(equation_level, "user_initial_guess", None),
getattr(equation_level, "user_bounds", None),
)
[docs]
def ask_num_parameters(parent_window: Any) -> Optional[Tuple[int, int]]:
"""
Dialog to ask for number of parameters and independent variables in a custom function.
Displays a dialog with Spinboxes allowing the user to select the number
of parameters (1-12) and number of independent variables (1-10) for a custom fitting function.
Args:
parent_window: Parent Tkinter window.
Returns:
Tuple of (number of parameters, number of independent variables), or ``None`` if the user closed
the window.
"""
num_parameter_level = Toplevel()
num_parameter_level.title(t("dialog.custom_formula_title"))
num_parameter_level.cancelled = False
num_parameter_level.numparam = IntVar(value=2)
num_parameter_level.numindep = IntVar(value=1)
def _on_close_num_parameters() -> None:
num_parameter_level.cancelled = True
num_parameter_level.destroy()
num_parameter_level.protocol("WM_DELETE_WINDOW", _on_close_num_parameters)
num_parameter_level.frame_custom = ttk.Frame(
num_parameter_level, padding=UI_STYLE["border_width"]
)
# Parameters label and spinbox
num_parameter_level.message = ttk.Label(
num_parameter_level.frame_custom,
text=t("dialog.num_parameters"),
)
num_parameter_level.num = Spinbox(
num_parameter_level.frame_custom,
textvariable=num_parameter_level.numparam,
from_=1,
to=12, # If you want more parameters, change this
wrap=True,
state="readonly",
width=UI_STYLE["spinbox_width"],
**SPINBOX_STYLE,
)
# Independent variables label and spinbox
num_parameter_level.message_indep = ttk.Label(
num_parameter_level.frame_custom,
text=t("dialog.num_independent_variables"),
)
num_parameter_level.num_indep = Spinbox(
num_parameter_level.frame_custom,
textvariable=num_parameter_level.numindep,
from_=1,
to=10,
wrap=True,
state="readonly",
width=UI_STYLE["spinbox_width"],
**SPINBOX_STYLE,
)
num_parameter_level.accept_button = ttk.Button(
num_parameter_level.frame_custom,
text=t("dialog.accept"),
command=num_parameter_level.destroy,
style="Primary.TButton",
width=UI_STYLE["button_width"],
)
_pad = UI_STYLE["padding"]
num_parameter_level.frame_custom.grid(column=0, row=0)
num_parameter_level.message.grid(column=0, row=0, padx=_pad, pady=_pad)
num_parameter_level.num.grid(column=1, row=0, padx=_pad, pady=_pad)
num_parameter_level.message_indep.grid(column=0, row=1, padx=_pad, pady=_pad)
num_parameter_level.num_indep.grid(column=1, row=1, padx=_pad, pady=_pad)
num_parameter_level.accept_button.grid(column=1, row=2, padx=_pad, pady=_pad)
bind_enter_to_accept(
[num_parameter_level.num, num_parameter_level.num_indep],
num_parameter_level.destroy,
)
num_parameter_level.num.focus_set()
num_parameter_level.resizable(False, False)
place_window_centered(num_parameter_level, preserve_size=True)
parent_window.wait_window(num_parameter_level)
if getattr(num_parameter_level, "cancelled", False):
return None
return (num_parameter_level.numparam.get(), num_parameter_level.numindep.get())
[docs]
def ask_parameter_names(parent_window: Any, num_params: int) -> List[str]:
"""
Dialog to ask for parameter names in a custom function.
Displays a series of dialogs (one per parameter) asking the user to enter
parameter names. Shows Unicode escape sequence hints for Greek letters.
Parameter names are normalized (Unicode escapes replaced, whitespace removed).
Args:
parent_window: Parent Tkinter window.
num_params: Number of parameters to collect names for.
Returns:
List of parameter names entered by the user. Returns ``[EXIT_SIGNAL]``
if user cancels at any point.
"""
exit_instruction = f'\n"{t("dialog.exit_option")}" {t("dialog.exit_instruction")}'
parameter_names_list: List[str] = []
for i in range(num_params):
parameter_asker_leve = Toplevel()
parameter_asker_leve.title(t("dialog.parameter_names_title"))
parameter_asker_leve.cancelled = False
parameter_asker_leve.name_parame = StringVar()
def _on_close_param(w: Any = parameter_asker_leve) -> None:
w.cancelled = True
w.destroy()
parameter_asker_leve.protocol(
"WM_DELETE_WINDOW", lambda w=parameter_asker_leve: _on_close_param(w)
)
parameter_asker_leve.frame_custom = ttk.Frame(
parameter_asker_leve,
padding=UI_STYLE["border_width"],
)
parameter_asker_leve.message = ttk.Label(
parameter_asker_leve.frame_custom,
text=t("dialog.parameter_name", index=i + 1),
)
parameter_asker_leve.codes = Text(
parameter_asker_leve.frame_custom,
bg=UI_STYLE["bg"],
fg=UI_STYLE["fg"],
font=(UI_STYLE["font_family"], UI_STYLE["font_size"]),
height=10,
width=UI_STYLE["entry_width"] * 2,
wrap="word",
borderwidth=0,
highlightthickness=0,
)
unicode_hint: str = t("dialog.custom_formula_unicode_hint")
parameter_asker_leve.codes.insert(
"1.0", _UNICODE_HINT_LINES + exit_instruction + "\n\n" + unicode_hint
)
parameter_asker_leve.codes.config(state="disabled")
parameter_asker_leve.name_entry = ttk.Entry(
parameter_asker_leve.frame_custom,
textvariable=parameter_asker_leve.name_parame,
width=UI_STYLE["entry_width"],
font=get_entry_font(),
)
parameter_asker_leve.accept_button = ttk.Button(
parameter_asker_leve.frame_custom,
text=t("dialog.accept"),
command=parameter_asker_leve.destroy,
style="Primary.TButton",
width=UI_STYLE["button_width"],
)
_pad = UI_STYLE["padding"]
parameter_asker_leve.frame_custom.grid(column=0, row=0)
parameter_asker_leve.codes.grid(
column=0, row=0, columnspan=2, padx=_pad, pady=6
)
parameter_asker_leve.message.grid(column=0, row=1, padx=_pad, pady=_pad)
parameter_asker_leve.name_entry.grid(column=1, row=1, padx=_pad, pady=_pad)
parameter_asker_leve.accept_button.grid(column=1, row=2, padx=_pad, pady=_pad)
bind_enter_to_accept(
[parameter_asker_leve.name_entry], parameter_asker_leve.destroy
)
apply_hover_to_children(parameter_asker_leve.frame_custom)
parameter_asker_leve.name_entry.focus_set()
place_window_centered(parameter_asker_leve, preserve_size=True)
parent_window.wait_window(parameter_asker_leve)
if getattr(parameter_asker_leve, "cancelled", False):
return [EXIT_SIGNAL]
raw_name: str = parameter_asker_leve.name_parame.get()
if not raw_name.strip():
return [EXIT_SIGNAL]
parameter_names_list.append(_normalize_param_name(raw_name))
return parameter_names_list
[docs]
def ask_num_fits(
parent_window: Any, min_val: int = 2, max_val: int = 10
) -> Optional[int]:
"""
Dialog to ask for number of multiple fits.
Displays a dialog with a Spinbox allowing the user to select the number
of fits to perform (between min_val and max_val).
Args:
parent_window: Parent Tkinter window.
min_val: Minimum number of fits allowed (default: 2).
max_val: Maximum number of fits allowed (default: 10).
Returns:
Selected number of fits (between ``min_val`` and ``max_val``), or
``None`` if the user closed the window.
"""
number_fits_level = Toplevel()
number_fits_level.title(t("workflow.multiple_fitting_title"))
number_fits_level.cancelled = False
def _on_close_num_fits() -> None:
number_fits_level.cancelled = True
number_fits_level.destroy()
number_fits_level.protocol("WM_DELETE_WINDOW", _on_close_num_fits)
number_fits_level.frame_custom = ttk.Frame(
number_fits_level,
padding=UI_STYLE["border_width"],
)
number_fits_level.num = IntVar()
number_fits_level.num_label = ttk.Label(
number_fits_level.frame_custom,
text=t("dialog.num_fits"),
)
number_fits_level.num_x = Spinbox(
number_fits_level.frame_custom,
textvariable=number_fits_level.num,
from_=min_val,
to=max_val,
wrap=True,
state="readonly",
width=UI_STYLE["spinbox_width"],
**SPINBOX_STYLE,
)
number_fits_level.accept_button = ttk.Button(
number_fits_level.frame_custom,
text=t("dialog.accept"),
command=number_fits_level.destroy,
style="Primary.TButton",
width=UI_STYLE["button_width"],
)
_pad = UI_STYLE["padding"]
number_fits_level.frame_custom.grid(column=0, row=0)
number_fits_level.num_label.grid(column=0, row=0, padx=_pad, pady=_pad)
number_fits_level.num_x.grid(column=1, row=0, padx=_pad, pady=_pad)
number_fits_level.accept_button.grid(column=1, row=1, padx=_pad, pady=_pad)
bind_enter_to_accept([number_fits_level.num_x], number_fits_level.destroy)
number_fits_level.num_x.focus_set()
number_fits_level.resizable(False, False)
place_window_centered(number_fits_level, preserve_size=True)
parent_window.wait_window(number_fits_level)
if getattr(number_fits_level, "cancelled", False):
return None
return number_fits_level.num.get()