Source code for frontend.ui_dialogs.equation_dialog

"""Equation selection dialog — choose predefined or write custom ODE."""

from __future__ import annotations

import tkinter as tk
from tkinter import messagebox, ttk
from typing import Any

from config import get_env_from_schema
from frontend.theme import get_font, get_select_colors
from frontend.ui_dialogs.keyboard_nav import setup_arrow_enter_navigation
from frontend.ui_dialogs.tooltip import ToolTip
from frontend.window_utils import bind_wraplength, fit_and_center, make_modal
from solver import load_predefined_equations


[docs] class EquationDialog: """Dialog for selecting or entering an ODE. Args: parent: Parent window. """ def __init__(self, parent: tk.Tk | tk.Toplevel) -> None: self.parent = parent self.win = tk.Toplevel(parent) self.win.title("Select Equation") bg: str = get_env_from_schema("UI_BACKGROUND") self.win.configure(bg=bg) self.equations = load_predefined_equations() self._filtered_keys: list[str] = [] self._selected_category: str | None = None self._selected_key: str | None = None self._equation_type_var = tk.StringVar(value="ode") self._build_ui() fit_and_center(self.win, min_width=1200, min_height=650) make_modal(self.win, parent) # ------------------------------------------------------------------ # UI construction # ------------------------------------------------------------------ def _build_ui(self) -> None: pad: int = get_env_from_schema("UI_PADDING") # ── Fixed bottom button bar ── btn_frame = ttk.Frame(self.win) btn_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=pad, pady=pad) btn_inner = ttk.Frame(btn_frame) btn_inner.pack() self._btn_next = ttk.Button( btn_inner, text="Next \u2192", command=self._on_next, ) self._btn_next.pack(side=tk.LEFT, padx=pad) btn_cancel = ttk.Button( btn_inner, text="Cancel", style="Cancel.TButton", command=self.win.destroy, ) btn_cancel.pack(side=tk.LEFT, padx=pad) setup_arrow_enter_navigation([[self._btn_next, btn_cancel]]) # ── Equation type selector ── type_frame = ttk.Frame(self.win) type_frame.pack(fill=tk.X, padx=pad, pady=(pad, 0)) ttk.Label(type_frame, text="Equation type:", style="Subtitle.TLabel").pack( side=tk.LEFT, padx=(0, pad) ) ttk.Radiobutton( type_frame, text="Differential (ODE)", variable=self._equation_type_var, value="ode", command=self._on_type_change, ).pack(side=tk.LEFT, padx=pad) ttk.Radiobutton( type_frame, text="Difference (recurrence)", variable=self._equation_type_var, value="difference", command=self._on_type_change, ).pack(side=tk.LEFT, padx=pad) ttk.Radiobutton( type_frame, text="Vector ODE", variable=self._equation_type_var, value="vector_ode", command=self._on_type_change, ).pack(side=tk.LEFT, padx=pad) ttk.Radiobutton( type_frame, text="PDE (multivariate)", variable=self._equation_type_var, value="pde", command=self._on_type_change, ).pack(side=tk.LEFT, padx=pad) # ── Notebook ── self._notebook = ttk.Notebook(self.win) self._notebook.pack(fill=tk.BOTH, expand=True, padx=pad, pady=pad) # --- Tab 1: Predefined --- predef_frame = ttk.Frame(self._notebook, padding=pad) self._notebook.add(predef_frame, text=" Predefined ") btn_bg: str = get_env_from_schema("UI_BUTTON_BG") fg: str = get_env_from_schema("UI_FOREGROUND") select_bg, select_fg = get_select_colors(element_bg=btn_bg, text_fg=fg) predef_frame.columnconfigure(0, weight=1) predef_frame.columnconfigure(1, weight=1) predef_frame.rowconfigure(0, weight=3) predef_frame.rowconfigure(1, weight=1) # Left column: category list (full height, 50% width) left = ttk.Frame(predef_frame) left.grid(row=0, column=0, rowspan=2, sticky="nsew", padx=(0, pad)) ttk.Label(left, text="Category:", style="Subtitle.TLabel").pack(anchor=tk.W) cat_list_frame = ttk.Frame(left) cat_list_frame.pack(fill=tk.BOTH, expand=True) cat_scrollbar = ttk.Scrollbar(cat_list_frame, orient=tk.VERTICAL) self.category_listbox = tk.Listbox( cat_list_frame, width=18, height=20, bg=btn_bg, fg=fg, selectbackground=select_bg, selectforeground=select_fg, font=get_font(), yscrollcommand=cat_scrollbar.set, exportselection=False, ) cat_scrollbar.config(command=self.category_listbox.yview) self.category_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) cat_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) self.category_listbox.bind("<<ListboxSelect>>", self._on_select_category) # Right column: equation list (3/4 height) + description (1/4 height), 50% width right_container = ttk.Frame(predef_frame) right_container.grid(row=0, column=1, rowspan=2, sticky="nsew") right_container.columnconfigure(0, weight=1) right_container.rowconfigure(0, weight=0) right_container.rowconfigure(1, weight=3) right_container.rowconfigure(2, weight=1) ttk.Label(right_container, text="Equation:", style="Subtitle.TLabel").grid( row=0, column=0, sticky="w" ) eq_list_frame = ttk.Frame(right_container) eq_list_frame.grid(row=1, column=0, sticky="nsew", pady=(pad, 0)) eq_list_frame.columnconfigure(0, weight=1) eq_list_frame.rowconfigure(0, weight=1) eq_scrollbar = ttk.Scrollbar(eq_list_frame, orient=tk.VERTICAL) self.eq_listbox = tk.Listbox( eq_list_frame, width=40, height=9, bg=btn_bg, fg=fg, selectbackground=select_bg, selectforeground=select_fg, font=get_font(), yscrollcommand=eq_scrollbar.set, exportselection=False, ) eq_scrollbar.config(command=self.eq_listbox.yview) self.eq_listbox.grid(row=0, column=0, sticky="nsew") eq_scrollbar.grid(row=0, column=1, sticky="ns") self.eq_listbox.bind("<<ListboxSelect>>", self._on_select_equation) desc_frame = ttk.LabelFrame(right_container, text="Information", padding=pad) desc_frame.grid(row=2, column=0, sticky="nsew", pady=(pad, 0)) desc_frame.columnconfigure(0, weight=1) desc_frame.rowconfigure(0, weight=1) self.desc_label = ttk.Label( desc_frame, text="", style="Small.TLabel", justify=tk.LEFT, ) self.desc_label.pack(anchor=tk.W, fill=tk.BOTH, expand=True) bind_wraplength(desc_frame, self.desc_label, pad=2 * pad) self._populate_category_list() # --- Tab 2: Custom --- self._custom_outer = ttk.Frame(self._notebook, padding=pad) self._notebook.add(self._custom_outer, text=" Custom ") # The custom content is rebuilt dynamically when equation type changes. self._custom_inner: ttk.Frame | None = None self._vec_expr_widgets: list[tk.Text] = [] self.category_listbox.focus_set() self._rebuild_custom_tab() # ------------------------------------------------------------------ # Event handlers # ------------------------------------------------------------------ def _get_categories_for_type(self) -> list[str]: """Return categories for equations matching the current equation type.""" eq_type = self._equation_type_var.get() return sorted( { eq.category for eq in self.equations.values() if getattr(eq, "equation_type", "ode") == eq_type } ) def _populate_category_list(self) -> None: """Populate the category listbox and select first category.""" categories = self._get_categories_for_type() self.category_listbox.delete(0, tk.END) for cat in categories: self.category_listbox.insert(tk.END, cat) self._selected_category = None self._filtered_keys = [] self.eq_listbox.delete(0, tk.END) self.desc_label.config(text="") if categories: self.category_listbox.selection_set(0) self._on_select_category(None) def _on_select_category(self, _event: tk.Event | None) -> None: # type: ignore[type-arg] """When category changes, populate the equation list.""" sel = self.category_listbox.curselection() if not sel: self._selected_category = None self._filtered_keys = [] self.eq_listbox.delete(0, tk.END) self.desc_label.config(text="") return categories = self._get_categories_for_type() idx = sel[0] self._selected_category = categories[idx] eq_type = self._equation_type_var.get() self._filtered_keys = [ k for k, eq in self.equations.items() if eq.category == self._selected_category and getattr(eq, "equation_type", "ode") == eq_type ] self.eq_listbox.delete(0, tk.END) for key in self._filtered_keys: self.eq_listbox.insert(tk.END, self.equations[key].name) self._selected_key = None self.desc_label.config(text="") if self._filtered_keys: self.eq_listbox.selection_set(0) self._on_select_equation(None) def _on_type_change(self) -> None: """When equation type changes, refresh predefined list and custom tab.""" self._populate_category_list() self._rebuild_custom_tab() def _rebuild_custom_tab(self) -> None: """Destroy and recreate the custom tab contents for the current equation type.""" if self._custom_inner is not None: self._custom_inner.destroy() pad: int = get_env_from_schema("UI_PADDING") _btn_bg: str = get_env_from_schema("UI_BUTTON_BG") _fg: str = get_env_from_schema("UI_FOREGROUND") _font = get_font() ci = ttk.Frame(self._custom_outer) ci.pack(fill=tk.BOTH, expand=True) self._custom_inner = ci self._vec_expr_widgets = [] eq_type = self._equation_type_var.get() # -- Hint -- if eq_type == "difference": hint_title = "Write f_{n+order} as a Python expression." hint_detail = ( "Use n for the index, f[0] for f_n, f[1] for f_{n+1}, etc.\n" "Example (geometric growth): r * f[0]" ) elif eq_type == "vector_ode": hint_title = "Write the highest derivative for each component." hint_detail = ( "Use f[i,k] where i = component index, k = derivative order.\n" "f[i,0] = component i, f[i,1] = its first derivative, etc.\n" "Example (coupled oscillators): -\u03c9**2 * f[0,0] + k * (f[1,0] - f[0,0])" ) elif eq_type == "pde": hint_title = "Select the LHS operator and write the RHS expression." hint_detail = ( "Choose which derivative operator equals the expression.\n" "Spatial: x, y (or x[0], x[1]). Solution: f. Derivatives: " "f[0]=f_x, f[1]=f_y, f[0,0]=f_xx, f[0,1]=f_xy, f[1,1]=f_yy.\n" "Example: -\u2207\u00b2f = sin(pi*x)*sin(pi*y) or Helmholtz: expression = f" ) else: hint_title = "Write the highest derivative as a Python expression." hint_detail = ( "Use f[0] or f for the function, f[1] for f\u2032, f[2] for f\u2033, etc. " "Use x for the independent variable.\n" "Example (harmonic oscillator): -\u03c9**2 * f[0]" ) self.custom_hint_label = ttk.Label(ci, text=hint_title, style="Subtitle.TLabel") self.custom_hint_label.pack(anchor=tk.W, pady=(0, pad)) self.custom_hint_text = tk.StringVar(value=hint_detail) self.custom_hint_detail = ttk.Label( ci, textvariable=self.custom_hint_text, style="Small.TLabel", justify=tk.LEFT ) self.custom_hint_detail.pack(anchor=tk.W, pady=(0, pad), fill=tk.X) bind_wraplength(ci, self.custom_hint_detail, pad=2 * pad) # -- Unicode reference -- unicode_frame = ttk.LabelFrame( ci, text="Unicode symbols — copy and paste directly", padding=pad ) unicode_frame.pack(fill=tk.X, pady=(0, pad)) _unicode_hint = ( "\u03b1 \u03b2 \u03b3 \u03b4 \u03b5 \u03b6 \u03b7" "\u03b8 \u03bb \u03bc \u03be \u03c0 \u03c1 \u03c3" "\u03c6 \u03c9 \u0394 \u03a3 \u03a6 \u03a9" ) _font_small = (_font[0], max(9, _font[1] - 4)) unicode_text = tk.Text( unicode_frame, height=1, bg=_btn_bg, fg=_fg, font=_font_small, borderwidth=0, highlightthickness=0, wrap="none", ) unicode_text.insert("1.0", _unicode_hint) unicode_text.config(state="disabled") unicode_text.pack(fill=tk.X) # -- Type-specific controls -- if eq_type == "vector_ode": self._build_custom_vector_ode(ci, pad, _btn_bg, _fg, _font) elif eq_type == "pde": self._build_custom_pde(ci, pad, _btn_bg, _fg, _font) else: self._build_custom_scalar(ci, pad, _btn_bg, _fg, _font, eq_type) def _build_custom_scalar( self, ci: ttk.Frame, pad: int, btn_bg: str, fg: str, font: Any, eq_type: str ) -> None: """Build the custom tab for scalar ODE / difference.""" row_order = ttk.Frame(ci) row_order.pack(fill=tk.X, pady=(pad, pad)) ttk.Label(row_order, text="Order:").pack(side=tk.LEFT) self.custom_order_var = tk.StringVar(value="2") ttk.Spinbox( row_order, from_=1, to=10, width=5, textvariable=self.custom_order_var, font=font, ).pack(side=tk.LEFT, padx=(pad, 0)) _primes = ["", "\u2032", "\u2033", "\u2034"] _superscript = "\u2070\u00b9\u00b2\u00b3\u2074\u2075\u2076\u2077\u2078\u2079" _subscript = "\u2080\u2081\u2082\u2083\u2084\u2085\u2086\u2087\u2088\u2089" def _ode_derivative_label(order_val: int) -> str: if order_val < len(_primes): prime = _primes[order_val] return f"Expression for f{prime}(x) =" sup = "".join(_superscript[int(d)] for d in str(order_val)) return f"Expression for f\u207d{sup}\u207e(x) =" def _difference_label(order_val: int) -> str: sub = "".join(_subscript[int(d)] for d in str(order_val)) return f"Expression for f\u2099\u208a{sub} =" # fₙ₊k def _update_expr_label(*_args: str) -> None: try: order_val = int(self.custom_order_var.get()) except ValueError: order_val = 4 order_val = max(1, min(10, order_val)) if eq_type == "difference": text = _difference_label(order_val) else: text = _ode_derivative_label(order_val) self._custom_expr_label.config(text=text) expr_label_text = ( _difference_label(2) if eq_type == "difference" else _ode_derivative_label(2) ) self._custom_expr_label = ttk.Label(ci, text=expr_label_text) self._custom_expr_label.pack(anchor=tk.W) self.custom_order_var.trace_add("write", _update_expr_label) self.custom_expr = tk.Text( ci, height=3, width=60, bg=btn_bg, fg=fg, insertbackground=fg, font=font, ) self.custom_expr.pack(fill=tk.X, pady=(4, pad)) ttk.Label(ci, text="Parameter names (comma-separated):").pack(anchor=tk.W) self.custom_params = ttk.Entry(ci, width=50, font=font) self.custom_params.pack(fill=tk.X, pady=(4, pad)) ToolTip(self.custom_params, "E.g.: \u03c9, \u03b3") def _build_custom_vector_ode( self, ci: ttk.Frame, pad: int, btn_bg: str, fg: str, font: Any ) -> None: """Build the custom tab for vector ODE (per-component expressions).""" top_row = ttk.Frame(ci) top_row.pack(fill=tk.X, pady=(pad, pad)) ttk.Label(top_row, text="Components:").pack(side=tk.LEFT) self._vec_n_var = tk.StringVar(value="2") vec_spin = ttk.Spinbox( top_row, from_=2, to=100, width=5, textvariable=self._vec_n_var, font=font, ) vec_spin.pack(side=tk.LEFT, padx=(pad, pad)) self._vec_n_refresh_id: str | None = None self._vec_n_var.trace_add("write", self._on_vec_n_change) # Dummy order var for compatibility (actual orders come from per-component spinboxes) self.custom_order_var = tk.StringVar(value="2") # Mode: per-component boxes or bulk expression mode_frame = ttk.Frame(ci) mode_frame.pack(fill=tk.X, pady=(0, pad)) self._vec_mode_var = tk.StringVar(value="per_component") ttk.Radiobutton( mode_frame, text="Per-component expressions", variable=self._vec_mode_var, value="per_component", command=self._on_vec_mode_change, ).pack(side=tk.LEFT, padx=(0, pad)) ttk.Radiobutton( mode_frame, text="Bulk expression (use i as loop variable)", variable=self._vec_mode_var, value="bulk", command=self._on_vec_mode_change, ).pack(side=tk.LEFT) # Container that switches between per-component and bulk self._vec_content_frame = ttk.Frame(ci) self._vec_content_frame.pack(fill=tk.BOTH, expand=True) # Per-component order spinbox variables self._vec_order_vars: list[tk.StringVar] = [] ttk.Label(ci, text="Parameter names (comma-separated):").pack(anchor=tk.W, pady=(pad, 0)) self.custom_params = ttk.Entry(ci, width=50, font=font) self.custom_params.pack(fill=tk.X, pady=(4, pad)) ToolTip(self.custom_params, "E.g.: \u03c9, k") self._refresh_vec_boxes() def _on_vec_n_change(self, *args: object) -> None: """Update expression boxes when number of components changes (debounced).""" if self._vec_n_refresh_id is not None: try: self.win.after_cancel(self._vec_n_refresh_id) except tk.TclError: pass self._vec_n_refresh_id = self.win.after(150, self._do_vec_n_refresh) def _do_vec_n_refresh(self) -> None: """Perform the actual refresh (called after debounce delay).""" self._vec_n_refresh_id = None self._refresh_vec_boxes() def _on_vec_mode_change(self) -> None: """Switch between per-component and bulk expression modes.""" self._refresh_vec_boxes() def _refresh_vec_boxes(self) -> None: """Rebuild the per-component or bulk expression widgets.""" for w in self._vec_content_frame.winfo_children(): w.destroy() self._vec_expr_widgets = [] self._vec_order_vars = [] self._vec_label_refs: list[ttk.Label] = [] _btn_bg: str = get_env_from_schema("UI_BUTTON_BG") _fg: str = get_env_from_schema("UI_FOREGROUND") _bg: str = get_env_from_schema("UI_BACKGROUND") _font = get_font() pad: int = get_env_from_schema("UI_PADDING") try: n = int(self._vec_n_var.get()) except ValueError: n = 2 mode = self._vec_mode_var.get() if mode == "bulk": # Bulk mode: single order spinbox + single expression template _primes_bulk = ["", "\u2032", "\u2033", "\u2034"] _superscript_bulk = "\u2070\u00b9\u00b2\u00b3\u2074\u2075\u2076\u2077\u2078\u2079" def _bulk_label(order_val: int) -> str: if order_val < len(_primes_bulk): prime = _primes_bulk[order_val] return f"Expression for f{prime}\u1d62(x) = (component i, use i as variable):" sup = "".join(_superscript_bulk[int(d)] for d in str(order_val)) return ( f"Expression for f\u207d{sup}\u207e\u1d62(x) = " "(component i, use i as variable):" ) bulk_top = ttk.Frame(self._vec_content_frame) bulk_top.pack(fill=tk.X, pady=(0, pad)) ttk.Label(bulk_top, text="Order per component:").pack(side=tk.LEFT) bulk_order_var = tk.StringVar(value="2") self._vec_order_vars.append(bulk_order_var) ttk.Spinbox( bulk_top, from_=1, to=10, width=5, textvariable=bulk_order_var, font=_font, ).pack(side=tk.LEFT, padx=(pad, 0)) self._vec_bulk_expr_label = ttk.Label( self._vec_content_frame, text=_bulk_label(2), ) self._vec_bulk_expr_label.pack(anchor=tk.W) def _on_bulk_order_change(*_args: str) -> None: try: val = int(bulk_order_var.get()) except ValueError: val = 2 val = max(1, min(10, val)) self._vec_bulk_expr_label.config(text=_bulk_label(val)) bulk_order_var.trace_add("write", _on_bulk_order_change) self._vec_bulk_expr = tk.Text( self._vec_content_frame, height=3, width=60, bg=_btn_bg, fg=_fg, insertbackground=_fg, font=_font, ) self._vec_bulk_expr.pack(fill=tk.X, pady=(4, pad)) else: # Per-component: each row has an order spinbox + label + expression box _primes = ["", "\u2032", "\u2033", "\u2034"] _superscript = "\u2070\u00b9\u00b2\u00b3\u2074\u2075\u2076\u2077\u2078\u2079" canvas = tk.Canvas( self._vec_content_frame, highlightthickness=0, height=150, bg=_bg, ) scrollbar = ttk.Scrollbar( self._vec_content_frame, orient=tk.VERTICAL, command=canvas.yview ) inner = tk.Frame(canvas, bg=_bg) canvas.create_window((0, 0), window=inner, anchor="nw") canvas.configure(yscrollcommand=scrollbar.set) canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) _sub_digits = "\u2080\u2081\u2082\u2083\u2084\u2085\u2086\u2087\u2088\u2089" def _sub(idx: int) -> str: if 0 <= idx < len(_sub_digits): return _sub_digits[idx] return "".join(_sub_digits[int(d)] if d.isdigit() else d for d in str(idx)) def _make_label_text(comp_idx: int, order_val: int) -> str: if order_val < len(_primes): prime = _primes[order_val] else: sup = "".join(_superscript[int(d)] for d in str(order_val)) prime = f"\u207d{sup}\u207e" return f"f{prime}{_sub(comp_idx)} =" for i in range(n): row = tk.Frame(inner, bg=_bg) row.pack(fill=tk.X, pady=2) # Order spinbox for this component order_var = tk.StringVar(value="2") self._vec_order_vars.append(order_var) ttk.Label(row, text="ord:", width=4).pack(side=tk.LEFT) spin = ttk.Spinbox( row, from_=1, to=10, width=3, textvariable=order_var, font=_font, ) spin.pack(side=tk.LEFT, padx=(0, pad)) # Label showing f″₀ = etc., updates when order changes lbl = ttk.Label(row, text=_make_label_text(i, 2), width=10) lbl.pack(side=tk.LEFT) self._vec_label_refs.append(lbl) # Bind order spinbox change to update label def _on_order_change( _var: str, _idx: str, _mode: str, comp=i, ov=order_var, lb=lbl ) -> None: try: val = int(ov.get()) except ValueError: val = 1 lb.config(text=_make_label_text(comp, val)) order_var.trace_add("write", _on_order_change) # Expression text box txt = tk.Text( row, height=1, width=45, bg=_btn_bg, fg=_fg, insertbackground=_fg, font=_font, ) txt.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(pad, 0)) self._vec_expr_widgets.append(txt) inner.update_idletasks() canvas.configure(scrollregion=canvas.bbox("all")) def _on_mousewheel(ev: tk.Event) -> str: # type: ignore[type-arg] if canvas.winfo_exists(): if hasattr(ev, "delta") and ev.delta != 0: canvas.yview_scroll(int(-1 * (ev.delta / 120)), "units") elif getattr(ev, "num", 0) == 5: canvas.yview_scroll(1, "units") elif getattr(ev, "num", 0) == 4: canvas.yview_scroll(-1, "units") return "break" def _bind_mousewheel(w: tk.Widget) -> None: w.bind("<MouseWheel>", _on_mousewheel) w.bind("<Button-4>", _on_mousewheel) w.bind("<Button-5>", _on_mousewheel) for child in w.winfo_children(): _bind_mousewheel(child) _bind_mousewheel(canvas) _bind_mousewheel(scrollbar) _bind_mousewheel(inner) def _build_custom_pde(self, ci: ttk.Frame, pad: int, btn_bg: str, fg: str, font: Any) -> None: """Build the custom tab for PDE.""" top_row = ttk.Frame(ci) top_row.pack(fill=tk.X, pady=(pad, pad)) ttk.Label(top_row, text="Independent variables:").pack(side=tk.LEFT) self._pde_nvars_var = tk.StringVar(value="2") nvars_spin = ttk.Spinbox( top_row, from_=2, to=2, width=5, textvariable=self._pde_nvars_var, font=font, state="readonly", ) nvars_spin.pack(side=tk.LEFT, padx=(pad, 0)) ToolTip(nvars_spin, "Number of independent variables (limited to 2)") self._pde_vars_label = ttk.Label( top_row, text=" x[0], x[1]", style="Small.TLabel", ) self._pde_vars_label.pack(side=tk.LEFT, padx=(pad, 0)) self.custom_order_var = tk.StringVar(value="2") # Operator selector (LHS of the PDE) op_row = ttk.Frame(ci) op_row.pack(fill=tk.X, pady=(0, pad)) ttk.Label(op_row, text="Left-hand side operator:").pack(side=tk.LEFT) self._pde_op_var = tk.StringVar(value="-\u2207\u00b2f (Poisson)") _pde_operators = [ "-\u2207\u00b2f (Poisson)", "\u2207\u00b2f (Laplacian)", "f\u2080\u2080", # f_00 (second deriv wrt x[0]) "f\u2081\u2081", # f_11 (second deriv wrt x[1]) "f\u2080\u2081", # f_01 (mixed partial) "f\u2080", # f_0 (first deriv wrt x[0]) "f\u2081", # f_1 (first deriv wrt x[1]) ] ttk.Combobox( op_row, textvariable=self._pde_op_var, values=_pde_operators, state="readonly", width=22, font=font, ).pack(side=tk.LEFT, padx=(pad, 0)) ttk.Label( ci, text="Right-hand side expression (use x[0], x[1] or x, y for variables):", ).pack(anchor=tk.W) self.custom_expr = tk.Text( ci, height=3, width=60, bg=btn_bg, fg=fg, insertbackground=fg, font=font, ) self.custom_expr.pack(fill=tk.X, pady=(4, pad)) ttk.Label(ci, text="Parameter names (comma-separated):").pack(anchor=tk.W) self.custom_params = ttk.Entry(ci, width=50, font=font) self.custom_params.pack(fill=tk.X, pady=(4, pad)) ToolTip(self.custom_params, "E.g.: k, \u03b1") def _on_next(self) -> None: """Route to predefined or custom handler based on active tab.""" idx = self._notebook.index(self._notebook.select()) if idx == 0: self._on_next_predefined() else: self._on_next_custom() def _on_select_equation(self, _event: tk.Event | None) -> None: # type: ignore[type-arg] sel = self.eq_listbox.curselection() if not sel: self.desc_label.config(text="") return idx = sel[0] key = self._filtered_keys[idx] eq = self.equations[key] self._selected_key = key self.desc_label.config(text=eq.description) def _on_next_predefined(self) -> None: if self._selected_key is None: messagebox.showwarning("No Selection", "Please select an equation.", parent=self.win) return eq = self.equations[self._selected_key] params: dict[str, float] = { pname: float(pinfo.get("default", 0.0)) for pname, pinfo in eq.parameters.items() } self.win.destroy() from frontend.ui_dialogs.parameters_dialog import ParametersDialog eq_type: str = getattr(eq, "equation_type", "ode") variables: list[str] = getattr(eq, "variables", ["x"]) vector_expressions: list[str] | None = getattr(eq, "vector_expressions", None) vector_components: int = getattr(eq, "vector_components", 1) ParametersDialog( self.parent, expression=eq.expression, function_name=eq.function_name, order=eq.order, parameters=params, parameters_schema=eq.parameters, equation_name=eq.name, default_y0=eq.default_initial_conditions, default_domain=eq.default_domain, display_formula=eq.formula, equation_type=eq_type, variables=variables, vector_expressions=vector_expressions, vector_components=vector_components, ) def _parse_custom_params(self) -> dict[str, float | list[float]] | None: """Parse custom parameter names from the entry. Values default to 0; the user sets them in the next dialog. Names of the form ``name[n]`` define a list parameter with *n* components. """ import re as _re from utils import normalize_unicode_escapes params: dict[str, float | list[float]] = {} raw_params = self.custom_params.get().strip() if raw_params: for name in raw_params.split(","): name = name.strip() if not name: continue normalized_name = normalize_unicode_escapes(name) # Detect list parameter pattern: name[n] m = _re.match(r"^(.+)\[(\d+)\]$", normalized_name) if m: n = int(m.group(2)) params[normalized_name] = [0.0] * n else: params[normalized_name] = 0.0 return params def _on_next_custom(self) -> None: eq_type = self._equation_type_var.get() if eq_type == "vector_ode": self._on_next_custom_vector() elif eq_type == "pde": self._on_next_custom_pde() else: self._on_next_custom_scalar() def _on_next_custom_scalar(self) -> None: from utils import normalize_unicode_escapes expr = normalize_unicode_escapes(self.custom_expr.get("1.0", tk.END).strip()) if not expr: messagebox.showwarning( "Empty Expression", "Please enter an expression.", parent=self.win ) return try: order = int(self.custom_order_var.get()) except ValueError: messagebox.showerror( "Invalid Order", "Order must be a positive integer.", parent=self.win ) return params = self._parse_custom_params() if params is None: return eq_type = self._equation_type_var.get() self.win.destroy() from frontend.ui_dialogs.parameters_dialog import ParametersDialog default_domain: list[float] = [0.0, 50.0] if eq_type == "difference" else [0.0, 10.0] ParametersDialog( self.parent, expression=expr, function_name=None, order=order, parameters=params, equation_name="Custom Difference" if eq_type == "difference" else "Custom ODE", default_y0=[1.0] * order, default_domain=default_domain, equation_type=eq_type, ) def _on_next_custom_vector(self) -> None: import re from utils import normalize_unicode_escapes try: n_components = int(self._vec_n_var.get()) except ValueError: messagebox.showerror( "Invalid Input", "Number of components must be an integer.", parent=self.win ) return mode = self._vec_mode_var.get() # Read per-component orders component_orders: list[int] = [] for idx, ov in enumerate(self._vec_order_vars): try: component_orders.append(int(ov.get())) except ValueError: messagebox.showerror( "Invalid Order", f"Order for component {idx} must be a positive integer.", parent=self.win, ) return if mode == "bulk": # Bulk mode: single order applies to all components order = component_orders[0] if component_orders else 2 component_orders = [order] * n_components bulk_expr = normalize_unicode_escapes(self._vec_bulk_expr.get("1.0", tk.END).strip()) if not bulk_expr: messagebox.showwarning( "Empty Expression", "Please enter a bulk expression.", parent=self.win ) return # Expand bulk expression for each component index vector_expressions = [] for i in range(n_components): expanded = re.sub(r"\bi\b", str(i), bulk_expr) vector_expressions.append(expanded) else: if len(self._vec_expr_widgets) != n_components: messagebox.showerror( "Mismatch", "Number of expression boxes doesn't match components. " "Click 'Refresh component boxes'.", parent=self.win, ) return vector_expressions = [] for idx, widget in enumerate(self._vec_expr_widgets): expr = normalize_unicode_escapes(widget.get("1.0", tk.END).strip()) if not expr: messagebox.showwarning( "Empty Expression", f"Expression for component {idx} is empty.", parent=self.win, ) return vector_expressions.append(expr) params = self._parse_custom_params() if params is None: return # Use max order for the pipeline (uniform assumption), pass component_orders for notation order = max(component_orders) if component_orders else 2 all_same = len(set(component_orders)) == 1 n_state = sum(component_orders) default_y0 = [0.0] * n_state self.win.destroy() from frontend.ui_dialogs.parameters_dialog import ParametersDialog ParametersDialog( self.parent, expression=None, function_name=None, order=order, parameters=params, equation_name="Custom Vector ODE", default_y0=default_y0, default_domain=[0.0, 10.0], equation_type="vector_ode", vector_expressions=vector_expressions, vector_components=n_components, component_orders=tuple(component_orders) if not all_same else None, ) def _on_next_custom_pde(self) -> None: from utils import normalize_unicode_escapes expr = normalize_unicode_escapes(self.custom_expr.get("1.0", tk.END).strip()) if not expr: messagebox.showwarning( "Empty Expression", "Please enter a PDE expression.", parent=self.win ) return try: n_vars = int(self._pde_nvars_var.get()) except ValueError: n_vars = 2 variables = [f"x[{i}]" for i in range(n_vars)] params = self._parse_custom_params() if params is None: return # Map UI operator label to internal operator key op_label = self._pde_op_var.get() _op_map = { "-\u2207\u00b2f (Poisson)": "neg_laplacian", "\u2207\u00b2f (Laplacian)": "laplacian", "f\u2080\u2080": "fxx", "f\u2081\u2081": "fyy", "f\u2080\u2081": "fxy", "f\u2080": "fx", "f\u2081": "fy", } pde_operator = _op_map.get(op_label, "neg_laplacian") self.win.destroy() from frontend.ui_dialogs.parameters_dialog import ParametersDialog default_domain = [0.0, 1.0, 0.0, 1.0] ParametersDialog( self.parent, expression=expr, function_name=None, order=2, parameters=params, equation_name="Custom PDE", default_y0=[], default_domain=default_domain, equation_type="pde", variables=variables, pde_operator=pde_operator, )