Source code for frontend.ui_dialogs.transform_dialog
"""Transform dialog — enter function, apply Fourier/Laplace/Taylor, visualize and export."""
from __future__ import annotations
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from typing import TYPE_CHECKING, Callable
import numpy as np
from config import (
generate_output_basename,
get_csv_path,
get_env_from_schema,
)
from frontend.plot_embed import embed_plot_in_tk
from frontend.theme import get_font
from frontend.ui_dialogs.collapsible_section import CollapsibleSection
from frontend.ui_dialogs.keyboard_nav import setup_arrow_enter_navigation
from frontend.ui_dialogs.scrollable_frame import ScrollableFrame
from frontend.ui_dialogs.tooltip import ToolTip
from frontend.window_utils import bind_wraplength, center_window, make_modal
from transforms import (
DisplayMode,
TransformKind,
apply_transform,
get_transform_coefficients,
parse_scalar_function,
)
from utils import EquationParseError, get_logger
if TYPE_CHECKING:
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
logger = get_logger(__name__)
_LEFT_WIDTH = 320 # Width of controls panel (similar to ResultDialog layout)
def _format_coefficient_title(meta: dict[str, object] | None) -> str:
"""Build a short title from coefficient metadata."""
if not meta:
return ""
parts: list[str] = []
domain = meta.get("domain")
if domain and isinstance(domain, (tuple, list)) and len(domain) >= 2:
x_min, x_max = float(domain[0]), float(domain[1])
parts.append(f"x in [{x_min:.2g}, {x_max:.2g}]")
n = meta.get("n_points")
if n is not None:
parts.append(f"n={n}")
if meta.get("taylor_order") is not None:
parts.append(f"order={meta['taylor_order']}")
if meta.get("taylor_center") is not None:
parts.append(f"center={meta['taylor_center']:.2g}")
s_range = meta.get("s_range")
if s_range and isinstance(s_range, (tuple, list)) and len(s_range) >= 2:
s_min, s_max = float(s_range[0]), float(s_range[1])
parts.append(f"s in [{s_min:.2g}, {s_max:.2g}]")
if not parts:
return ""
return " | ".join(parts)
[docs]
class TransformDialog:
"""Dialog for entering a function, applying transforms, and exporting data.
Args:
parent: Parent window.
"""
def __init__(self, parent: tk.Tk | tk.Toplevel) -> None:
self.parent = parent
self.win = tk.Toplevel(parent)
self.win.title("Function Transforms")
bg: str = get_env_from_schema("UI_BACKGROUND")
self.win.configure(bg=bg)
self._func: object = None # Callable | None
self._current_x: np.ndarray | None = None
self._current_y: np.ndarray | None = None
self._current_xlabel: str = "x"
self._current_ylabel: str = "f"
self._current_metadata: dict[str, object] | None = None
self._y_original: np.ndarray | None = None # For Taylor overlay
self._show_coefficients: bool = False # Display mode: curve vs coefficients
self._canvas: FigureCanvasTkAgg | None = None
self._fig: Figure | None = None
self._ax = None
self._build_ui()
# Size window from plot dimensions in .env (like ResultDialog)
pad: int = get_env_from_schema("UI_PADDING")
screen_w = self.win.winfo_screenwidth()
screen_h = self.win.winfo_screenheight()
fig_w: int = get_env_from_schema("PLOT_FIGSIZE_WIDTH")
fig_h: int = get_env_from_schema("PLOT_FIGSIZE_HEIGHT")
aspect: float = fig_w / fig_h if fig_h else 2.0
win_w = int(screen_w * 0.88)
right_w = win_w - _LEFT_WIDTH - 3 * pad
plot_h = int(right_w / aspect)
chrome_h = 30 + 46 + 2 * pad # toolbar + button bar + padding
win_h = min(max(plot_h + chrome_h, 500), int(screen_h * 0.92))
center_window(self.win, win_w, win_h, max_width_ratio=0.92, resizable=True)
self.win.minsize(_LEFT_WIDTH + 500, 500)
make_modal(self.win, parent)
def _build_ui(self) -> None:
"""Build the dialog layout."""
pad: int = get_env_from_schema("UI_PADDING")
_font = get_font()
btn_bg: str = get_env_from_schema("UI_BUTTON_BG")
fg: str = get_env_from_schema("UI_FOREGROUND")
# ── 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(anchor=tk.CENTER)
btn_help = ttk.Button(
btn_inner,
text="Help",
command=self._on_help,
)
btn_help.pack(side=tk.LEFT, padx=pad)
ToolTip(btn_help, "Show help about the Transforms section.")
btn_export = ttk.Button(
btn_inner,
text="Export CSV",
command=self._on_export,
)
btn_export.pack(side=tk.LEFT, padx=pad)
ToolTip(btn_export, "Export the transformed data points to a CSV file.")
btn_close = ttk.Button(
btn_inner,
text="Close",
style="Cancel.TButton",
command=self.win.destroy,
)
btn_close.pack(side=tk.LEFT, padx=pad)
setup_arrow_enter_navigation([[btn_help, btn_export, btn_close]])
# ── Main content ──
content = ttk.Frame(self.win)
content.pack(fill=tk.BOTH, expand=True, padx=pad, pady=pad)
# ── Left: controls ──
left = ttk.Frame(content)
left.pack(side=tk.LEFT, fill=tk.Y, padx=(0, pad))
# Function
func_lf = ttk.LabelFrame(left, text="Function f(x)", padding=pad)
func_lf.pack(fill=tk.X, pady=(0, pad))
func_hint_lbl = ttk.Label(
func_lf,
text="Use x as variable. Example: sin(x), exp(-a*x)",
style="Small.TLabel",
)
func_hint_lbl.pack(anchor=tk.W)
# Unicode symbols — copy and paste directly
_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(
func_lf,
height=1,
width=30,
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, pady=(2, 4))
bind_wraplength(func_lf, func_hint_lbl, pad=2 * pad, min_wrap=150)
self._func_entry = tk.Text(
func_lf,
height=2,
width=30,
bg=btn_bg,
fg=fg,
insertbackground=fg,
font=_font,
)
self._func_entry.insert("1.0", "sin(x)")
self._func_entry.pack(fill=tk.X, pady=(4, pad))
ttk.Label(func_lf, text="Parameters (name=value, comma-separated):").pack(anchor=tk.W)
self._params_entry = ttk.Entry(func_lf, width=32, font=_font)
self._params_entry.pack(fill=tk.X, pady=(4, pad))
ToolTip(self._params_entry, "E.g.: a=1.0, omega=2.0")
row1 = ttk.Frame(func_lf)
row1.pack(fill=tk.X, pady=(pad, 0))
ttk.Label(row1, text="x\u2098\u1d62\u2099:").pack(side=tk.LEFT) # x_min
self._x_min_var = tk.StringVar(value="-10")
ttk.Entry(row1, textvariable=self._x_min_var, width=10, font=_font).pack(
side=tk.LEFT, padx=(4, pad)
)
ttk.Label(row1, text="x\u2098\u2090\u2093:").pack(side=tk.LEFT, padx=(pad, 0)) # x_max
self._x_max_var = tk.StringVar(value="10")
ttk.Entry(row1, textvariable=self._x_max_var, width=10, font=_font).pack(
side=tk.LEFT, padx=(4, pad)
)
# Apply button (below function/range)
self._btn_apply = ttk.Button(
left,
text="Apply / Update plot",
command=self._on_apply,
)
self._btn_apply.pack(fill=tk.X, pady=(0, pad))
ToolTip(self._btn_apply, "Parse function and apply selected transformation.")
# Transform
trans_lf = ttk.LabelFrame(left, text="Transformation", padding=pad)
trans_lf.pack(fill=tk.X, pady=(0, pad))
self._transform_var = tk.StringVar(value=TransformKind.ORIGINAL.value)
self._transform_combo = ttk.Combobox(
trans_lf,
textvariable=self._transform_var,
values=[k.value for k in TransformKind],
state="readonly",
width=28,
font=_font,
)
self._transform_combo.pack(fill=tk.X, pady=(0, pad))
self._transform_combo.bind("<<ComboboxSelected>>", self._on_transform_change)
# Display mode (curve vs coefficients)
ttk.Label(trans_lf, text="Display:", style="Small.TLabel").pack(anchor=tk.W)
self._display_var = tk.StringVar(value=DisplayMode.CURVE.value)
display_combo = ttk.Combobox(
trans_lf,
textvariable=self._display_var,
values=[k.value for k in DisplayMode],
state="readonly",
width=26,
font=_font,
)
display_combo.pack(fill=tk.X, pady=(2, pad))
display_combo.bind("<<ComboboxSelected>>", self._on_display_change)
# Taylor options (shown only when Taylor is selected)
self._taylor_frame = ttk.Frame(trans_lf)
self._taylor_frame.pack(fill=tk.X, pady=(0, pad))
ttk.Label(self._taylor_frame, text="Taylor Order:").pack(side=tk.LEFT)
self._taylor_order_var = tk.StringVar(value="5")
ttk.Spinbox(
self._taylor_frame,
from_=1,
to=15,
width=5,
textvariable=self._taylor_order_var,
font=_font,
).pack(side=tk.LEFT, padx=(4, pad))
ttk.Label(self._taylor_frame, text="Center:").pack(side=tk.LEFT, padx=(pad, 0))
self._taylor_center_var = tk.StringVar(value="0")
ttk.Entry(
self._taylor_frame,
textvariable=self._taylor_center_var,
width=8,
font=_font,
).pack(side=tk.LEFT, padx=(4, 0))
# ── Right: plot ──
plot_frame = ttk.Frame(content)
plot_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
self._plot_container = ttk.Frame(plot_frame)
self._plot_container.pack(fill=tk.BOTH, expand=True)
self._on_apply()
def _on_transform_change(self, _event: object) -> None:
"""When transform selection changes, refresh the plot."""
self._on_apply()
def _on_display_change(self, _event: object) -> None:
"""When display mode changes, refresh the plot."""
self._on_apply()
def _on_help(self) -> None:
"""Show help window for the Transforms section."""
_TransformHelpDialog(self.win, on_close=lambda: self.win.grab_set())
def _parse_inputs(self) -> tuple[object, float, float, dict[str, float]]:
"""Parse function expression, range, and parameters.
Returns:
(func, x_min, x_max, params).
Raises:
ValueError: On parse error.
"""
from utils import normalize_unicode_escapes
expr = normalize_unicode_escapes(self._func_entry.get("1.0", tk.END).strip())
if not expr:
raise ValueError("Please enter a function expression.")
params: dict[str, float] = {}
raw = self._params_entry.get().strip()
if raw:
for pair in raw.split(","):
pair = pair.strip()
if "=" in pair:
name, val_str = pair.split("=", 1)
name = normalize_unicode_escapes(name.strip())
try:
params[name] = float(val_str.strip())
except ValueError:
raise ValueError(f"Parameter '{name}' must be a number.")
try:
x_min = float(self._x_min_var.get())
x_max = float(self._x_max_var.get())
except ValueError:
raise ValueError("x_min and x_max must be numbers.")
if x_min >= x_max:
raise ValueError("x_min must be less than x_max.")
func = parse_scalar_function(expr, params)
return func, x_min, x_max, params
def _on_apply(self) -> None:
"""Parse inputs, apply transform, and update the plot."""
try:
func, x_min, x_max, _params = self._parse_inputs()
except ValueError as exc:
messagebox.showerror("Invalid Input", str(exc), parent=self.win)
return
except EquationParseError as exc:
messagebox.showerror("Parse Error", str(exc), parent=self.win)
return
kind_str = self._transform_var.get()
try:
kind = TransformKind(kind_str)
except ValueError:
kind = TransformKind.ORIGINAL
taylor_order = 5
taylor_center: float | None = None
if kind == TransformKind.TAYLOR:
try:
taylor_order = int(self._taylor_order_var.get())
taylor_order = max(1, min(15, taylor_order))
except ValueError:
pass
try:
taylor_center = float(self._taylor_center_var.get())
except ValueError:
taylor_center = (x_min + x_max) / 2
n_points = 512
if kind == TransformKind.LAPLACE:
n_points = 200
display_str = self._display_var.get()
try:
display_mode = DisplayMode(display_str)
except ValueError:
display_mode = DisplayMode.CURVE
self._show_coefficients = display_mode == DisplayMode.COEFFICIENTS
try:
if self._show_coefficients:
x, y, xlabel, ylabel, metadata = get_transform_coefficients(
func,
kind,
x_min,
x_max,
n_points=n_points,
taylor_order=taylor_order,
taylor_center=taylor_center,
)
else:
x, y, xlabel, ylabel = apply_transform(
func,
kind,
x_min,
x_max,
n_points=n_points,
taylor_order=taylor_order,
taylor_center=taylor_center,
)
metadata = None
except Exception as exc:
logger.exception("Transform failed")
messagebox.showerror(
"Transform Error",
f"Could not compute transform: {exc}",
parent=self.win,
)
return
self._func = func
self._current_x = x
self._current_y = y
self._current_xlabel = xlabel
self._current_ylabel = ylabel
self._current_metadata = metadata if self._show_coefficients else None
if kind == TransformKind.TAYLOR and not self._show_coefficients:
from transforms import compute_function_samples
x_orig, y_orig = compute_function_samples(func, x_min, x_max, n_points)
self._y_original = y_orig
else:
self._y_original = None
self._redraw_plot()
def _redraw_plot(self) -> None:
"""Redraw the plot with current data."""
import matplotlib.pyplot as plt
from config import get_env_from_schema
from plotting import _apply_plot_style, _finalize_plot
if self._current_x is None or self._current_y is None:
return
x = self._current_x
y = self._current_y
xlabel = self._current_xlabel
ylabel = self._current_ylabel
if self._fig is None:
_apply_plot_style()
width: int = get_env_from_schema("PLOT_FIGSIZE_WIDTH")
height: int = get_env_from_schema("PLOT_FIGSIZE_HEIGHT")
dpi: int = get_env_from_schema("DPI")
self._fig, self._ax = plt.subplots(figsize=(width, height), dpi=dpi)
self._canvas = embed_plot_in_tk(self._fig, self._plot_container)
else:
self._ax = self._fig.axes[0] if self._fig.axes else None
if self._ax is None:
self._ax = self._fig.add_subplot(111)
self._ax.clear()
line_color: str = get_env_from_schema("PLOT_LINE_COLOR")
line_width: float = get_env_from_schema("PLOT_LINE_WIDTH")
if self._show_coefficients:
markerline, stemlines, baseline = self._ax.stem(
x,
y,
linefmt="-",
markerfmt="o",
basefmt=" ",
label=ylabel,
)
plt.setp(markerline, color=line_color)
plt.setp(stemlines, color=line_color)
else:
self._ax.plot(x, y, color=line_color, linewidth=line_width, label=ylabel)
if self._y_original is not None:
self._ax.plot(
x,
self._y_original,
color="coral",
linewidth=line_width * 0.8,
linestyle="--",
label="f(x)",
)
if self._y_original is not None:
self._ax.legend()
title = _format_coefficient_title(self._current_metadata) if self._show_coefficients else ""
_finalize_plot(
self._ax,
title=title,
xlabel=xlabel,
ylabel=ylabel,
legend=(self._y_original is not None),
)
self._fig.tight_layout()
if self._canvas:
self._canvas.draw_idle()
def _on_export(self) -> None:
"""Export the transformed data to CSV."""
if self._current_x is None or self._current_y is None:
messagebox.showwarning(
"No Data",
"Apply a transformation first to generate data.",
parent=self.win,
)
return
default_path = get_csv_path(generate_output_basename(prefix="transform"))
filepath = filedialog.asksaveasfilename(
parent=self.win,
defaultextension=".csv",
initialfile=default_path.name,
initialdir=str(default_path.parent),
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
)
if not filepath:
return
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
headers = [self._current_xlabel, self._current_ylabel]
data = np.column_stack([self._current_x, self._current_y])
with open(path, "w", newline="", encoding="utf-8") as f:
import csv
if self._current_metadata:
for k, v in self._current_metadata.items():
f.write(f"# {k}: {v}\n")
writer = csv.writer(f)
writer.writerow(headers)
writer.writerows(data.tolist())
logger.info("Transform data exported: %s", path)
messagebox.showinfo(
"Export Complete",
f"Data exported to:\n{path}",
parent=self.win,
)
_TRANSFORM_HELP_ABOUT = (
"This dialog lets you apply classical mathematical transforms to any scalar "
"function f(x). Enter an expression, pick a transform, and instantly "
"visualise the result. You can switch between a curve view and a "
"coefficients view, and export the data to CSV or save the plot as an image."
)
_TRANSFORM_HELP_HOW_TO_USE = (
"1. Type a function in the f(x) field (e.g. sin(x), exp(-a*x)).\n"
"2. (Optional) Add parameters in the Parameters field, e.g. a=1.0, omega=2.\n"
"3. Set the sampling range with x\u2098\u1d62\u2099 and x\u2098\u2090\u2093.\n"
"4. Choose a transformation from the dropdown.\n"
"5. For Taylor: adjust Order (1\u201315) and Centre as needed.\n"
"6. Pick Display mode: Curve (function vs domain) or "
"Coefficients (a\u1d62 vs index).\n"
"7. The plot updates automatically when you change any setting.\n"
"8. Use Export CSV or the Matplotlib toolbar (floppy-disk icon) to save."
)
_TRANSFORM_HELP_INPUT = (
"Function: Use x as the independent variable. Example: sin(x), exp(-a*x)\n\n"
"Parameters: name=value, comma-separated (e.g. a=1.0, omega=2). "
"Unicode symbols (ω, γ, etc.) are supported.\n\n"
"Range: x\u2098\u1d62\u2099 and x\u2098\u2090\u2093 define the sampling domain.\n\n"
"Available mathematical functions: sin, cos, tan, exp, log, log10, sqrt, abs, "
"sinh, cosh, tanh, arcsin, arccos, arctan, floor, ceil, sign, heaviside, pi, e."
)
_TRANSFORM_HELP_TRANSFORMS = (
"Original (f(x)) — Plot the function over the specified range.\n\n"
"Fourier (FFT) — Discrete Fourier transform magnitude spectrum |F(ω)|.\n\n"
"Laplace (real axis) — Laplace transform L(s) = ∫f(t)e^{-st}dt over the s range "
"(default 0.1 to 10).\n\n"
"Taylor series — Polynomial expansion around a center point. Set Taylor Order "
"(1–15) and Center. Uses least-squares fitting for stability.\n\n"
"Hilbert (discrete) — Discrete Hilbert transform H[f](x).\n\n"
"Z-transform (discrete) — Magnitude spectrum (DFT on unit circle)."
)
_TRANSFORM_HELP_DISPLAY = (
"Curve (f vs x) — Plot the transformed function or spectrum versus its domain. "
"For Taylor, the original f(x) is displayed as a dashed overlay for comparison.\n\n"
"Coefficients — Plot coefficients versus index (metadata shown in plot title):\n"
" Taylor: a\u1d62 vs i (degree)\n"
" Fourier: |F(ω)| vs ω/(2π)\n"
" Laplace: L(s) vs s\n"
" Hilbert: |H(ω)| vs ω/(2π)\n"
" Z-transform: |X(ω)| vs ω/(2π)"
)
_TRANSFORM_HELP_EXPORT = (
"Export CSV — Saves the currently displayed data (curve or coefficients) to a "
"CSV file. A file dialog is displayed to select the save location.\n\n"
"Matplotlib toolbar — A toolbar is displayed below the plot. Use the save "
"button to export the plot as PNG, JPG, or PDF."
)
_TRANSFORM_HELP_SECTIONS: list[tuple[str, str]] = [
("About", _TRANSFORM_HELP_ABOUT),
("How to Use", _TRANSFORM_HELP_HOW_TO_USE),
("Function Input", _TRANSFORM_HELP_INPUT),
("Transformations", _TRANSFORM_HELP_TRANSFORMS),
("Display Mode", _TRANSFORM_HELP_DISPLAY),
("Export", _TRANSFORM_HELP_EXPORT),
]
class _TransformHelpDialog:
"""Help window for the Transforms section, with collapsible sections."""
def __init__(
self,
parent: tk.Tk | tk.Toplevel,
on_close: Callable[[], None] | None = None,
) -> None:
self.win = tk.Toplevel(parent)
self.win.title("Transforms — Help")
self._on_close = on_close
bg: str = get_env_from_schema("UI_BACKGROUND")
self.win.configure(bg=bg)
# Take grab so collapsibles receive clicks (parent had grab from make_modal)
parent.grab_release()
self.win.grab_set()
self._body_labels: list[ttk.Label] = []
def _do_close() -> None:
if self._on_close:
self._on_close()
self.win.destroy()
self._do_close = _do_close
self._build_ui()
from frontend.window_utils import fit_and_center
fit_and_center(self.win, min_width=780, min_height=520)
self.win.transient(parent)
self.win.protocol("WM_DELETE_WINDOW", self._do_close)
def _build_ui(self) -> None:
pad: int = get_env_from_schema("UI_PADDING")
bg: str = get_env_from_schema("UI_BACKGROUND")
btn_frame = ttk.Frame(self.win)
btn_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=pad, pady=pad)
btn_close = ttk.Button(
btn_frame,
text="Close",
style="Cancel.TButton",
command=self._do_close,
)
btn_close.pack()
setup_arrow_enter_navigation([[btn_close]])
btn_close.focus_set()
self._scroll = ScrollableFrame(self.win)
self._scroll.apply_bg(bg)
self._scroll.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
inner = self._scroll.inner
inner.configure(padding=pad)
ttk.Label(
inner,
text="Transforms — Help",
style="Title.TLabel",
).pack(anchor=tk.W, pady=(0, pad))
first = True
for title, body in _TRANSFORM_HELP_SECTIONS:
self._add_section(inner, title, body, expanded=first)
first = False
self._scroll.bind_new_children()
bind_wraplength(inner, self._body_labels, pad=48, min_wrap=200)
def _add_section(
self,
parent: ttk.Frame,
title: str,
body: str,
*,
expanded: bool = False,
) -> None:
pad: int = get_env_from_schema("UI_PADDING")
section = CollapsibleSection(
parent,
self._scroll,
title,
expanded=expanded,
pad=pad,
)
body_lbl = ttk.Label(
section.content,
text=body,
justify=tk.LEFT,
)
body_lbl.pack(anchor=tk.W, fill=tk.X)
self._body_labels.append(body_lbl)