"""Tkinter embedding utilities for matplotlib figures."""
from __future__ import annotations
import tkinter as tk
import warnings
from tkinter import ttk
from typing import TYPE_CHECKING, Callable
from config import get_env_from_schema
from frontend.theme import get_font
from utils import get_logger
logger = get_logger(__name__)
if TYPE_CHECKING:
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
def _get_max_fps() -> int:
"""Return configured max FPS for animation playback."""
return int(get_env_from_schema("ANIMATION_MAX_FPS"))
def _bind_resize_handler(
canvas: FigureCanvasTkAgg,
fig: Figure,
) -> None:
"""Bind a resize handler to update tight_layout and redraw.
Args:
canvas: The FigureCanvasTkAgg object.
fig: The Matplotlib figure.
"""
def _on_resize(_event: object, _fig: object = fig, _canvas: object = canvas) -> None:
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
_fig.tight_layout() # type: ignore[union-attr]
_canvas.draw_idle() # type: ignore[union-attr]
except Exception as exc:
logger.debug("Plot resize handler failed: %s", exc)
canvas.mpl_connect("resize_event", _on_resize)
[docs]
def embed_animation_plot_in_tk(
fig: Figure,
parent: tk.Widget,
*,
on_export_mp4: Callable[[float], None] | None = None,
) -> FigureCanvasTkAgg:
"""Embed an animation figure with Scale, Play button, duration entry, and Export MP4.
The figure must have _animation_update(idx) and _animation_n_points attributes.
Duration (seconds) controls both playback speed and MP4 export length.
Args:
fig: Matplotlib figure from create_vector_animation_plot.
parent: Tkinter parent widget.
on_export_mp4: Optional callback(duration_seconds) when user clicks Export MP4.
Returns:
The canvas object.
"""
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
update_fn = getattr(fig, "_animation_update", None)
n_points = getattr(fig, "_animation_n_points", 0)
initial_idx = getattr(fig, "_animation_initial_index", 0)
top_frame = ttk.Frame(parent)
top_frame.pack(fill=tk.BOTH, expand=True)
canvas = FigureCanvasTkAgg(fig, master=top_frame)
tb = NavigationToolbar2Tk(canvas, top_frame)
tb.update()
tb.pack(side=tk.BOTTOM, fill=tk.X)
widget = canvas.get_tk_widget()
widget.config(width=1, height=1)
widget.pack(fill=tk.BOTH, expand=True)
_bind_resize_handler(canvas, fig)
canvas.draw()
ctrl_frame = ttk.Frame(parent)
ctrl_frame.pack(fill=tk.X, pady=(4, 0))
_play_job: str | None = None
scale_var = tk.IntVar(value=initial_idx)
duration_var = tk.StringVar(value="10")
root = parent.winfo_toplevel()
def _get_duration_sec() -> float:
try:
d = float(duration_var.get())
return max(0.5, d)
except (ValueError, TypeError):
return 10.0
def _on_scale_change(v: str) -> None:
idx = int(float(v))
if update_fn is not None:
update_fn(idx)
def _play_tick() -> None:
nonlocal _play_job
try:
idx = scale_var.get()
except tk.TclError:
_play_job = None
return
if idx >= n_points - 1:
_play_job = None
return
duration_sec = _get_duration_sec()
max_fps = _get_max_fps()
ticks_total = max(1, int(max_fps * duration_sec))
step = max(1, n_points // ticks_total)
new_idx = min(idx + step, n_points - 1)
scale_var.set(new_idx)
if update_fn is not None:
update_fn(new_idx)
interval_ms = max(34, int(1000.0 / max_fps))
_play_job = root.after(interval_ms, _play_tick)
def _on_play() -> None:
nonlocal _play_job
if _play_job is not None:
try:
root.after_cancel(_play_job)
except tk.TclError:
pass
_play_job = None
if scale_var.get() >= n_points - 1:
scale_var.set(0)
if update_fn is not None:
update_fn(0)
_play_tick()
def _on_stop() -> None:
nonlocal _play_job
if _play_job is not None:
try:
root.after_cancel(_play_job)
except tk.TclError:
pass
_play_job = None
if update_fn is not None and n_points > 0:
scale = ttk.Scale(
ctrl_frame,
from_=0,
to=max(1, n_points - 1),
variable=scale_var,
orient=tk.HORIZONTAL,
command=_on_scale_change,
)
ttk.Label(ctrl_frame, text="x:").pack(side=tk.LEFT, padx=(0, 4))
scale.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=4)
ttk.Label(ctrl_frame, text="Duration (s):").pack(side=tk.LEFT, padx=(8, 2))
ttk.Entry(ctrl_frame, textvariable=duration_var, width=5, font=get_font()).pack(
side=tk.LEFT, padx=2
)
ttk.Button(
ctrl_frame,
text="▶",
width=3,
style="Small.TButton",
command=_on_play,
).pack(side=tk.LEFT, padx=4)
ttk.Button(
ctrl_frame,
text="■",
width=3,
style="Small.TButton",
command=_on_stop,
).pack(side=tk.LEFT, padx=2)
if on_export_mp4 is not None:
btn = ttk.Button(
ctrl_frame,
text="Export MP4",
command=lambda: on_export_mp4(_get_duration_sec()),
)
btn.pack(side=tk.RIGHT, padx=4)
return canvas
[docs]
def embed_plot_in_tk(
fig: Figure,
parent: tk.Widget,
toolbar: bool = True,
) -> FigureCanvasTkAgg:
"""Embed a matplotlib figure in a Tkinter parent widget.
Args:
fig: Figure to embed.
parent: Tkinter parent (Frame, Toplevel, etc.).
toolbar: Whether to add the navigation toolbar.
Returns:
The canvas object.
"""
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
canvas = FigureCanvasTkAgg(fig, master=parent)
if toolbar:
tb = NavigationToolbar2Tk(canvas, parent)
tb.update()
tb.pack(side=tk.BOTTOM, fill=tk.X)
widget = canvas.get_tk_widget()
# Override the natural size so tkinter can freely size the widget from
# available space; FigureCanvasTkAgg redraws at the actual allocated size.
widget.config(width=1, height=1)
widget.pack(fill=tk.BOTH, expand=True)
_bind_resize_handler(canvas, fig)
canvas.draw()
return canvas