"""Configuration dialog — edit .env variables with collapsible sections."""
from __future__ import annotations
import tkinter as tk
from tkinter import messagebox, ttk
from typing import Any
from config import (
ENV_SCHEMA,
SCHEMA_BY_KEY,
get_current_env_values,
get_env_from_schema,
get_env_path,
write_env_file,
)
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.window_utils import bind_wraplength, fit_and_center, make_modal
from utils import get_logger
logger = get_logger(__name__)
_SECTION_ORDER: list[tuple[str, str, list[str]]] = [
(
"ui_theme",
"UI Theme",
[
"UI_BACKGROUND",
"UI_FOREGROUND",
"UI_BUTTON_BG",
"UI_BUTTON_WIDTH",
"UI_BUTTON_FG",
"UI_BUTTON_FG_CANCEL",
"UI_BUTTON_FG_ACCENT2",
"UI_FONT_SIZE",
"UI_FONT_FAMILY",
"UI_PADDING",
],
),
(
"ui_tooltips",
"UI Tooltips",
[
"UI_TOOLTIP_DELAY_MS",
"UI_TOOLTIP_WRAPLENGTH",
"UI_TOOLTIP_PADX",
"UI_TOOLTIP_PADY",
],
),
(
"plot_style",
"Plot Style",
[
"PLOT_FIGSIZE_WIDTH",
"PLOT_FIGSIZE_HEIGHT",
"DPI",
"PLOT_SHOW_TITLE",
"PLOT_SHOW_GRID",
"PLOT_LINE_COLOR",
"PLOT_LINE_WIDTH",
"PLOT_LINE_STYLE",
"PLOT_COLOR_SCHEME",
],
),
(
"plot_fonts",
"Plot Fonts",
[
"FONT_FAMILY",
"FONT_TITLE_SIZE",
"FONT_TITLE_WEIGHT",
"FONT_AXIS_SIZE",
"FONT_AXIS_STYLE",
"FONT_TICK_SIZE",
],
),
(
"plot_markers",
"Plot Markers",
[
"PLOT_MARKER_FORMAT",
"PLOT_MARKER_SIZE",
"PLOT_MARKER_FACE_COLOR",
"PLOT_MARKER_EDGE_COLOR",
],
),
(
"plot_phase",
"Plot Phase-Space",
[
"PLOT_PHASE_START_COLOR",
"PLOT_PHASE_END_COLOR",
"PLOT_PHASE_MARKER_SIZE",
],
),
(
"plot_surface",
"Plot 3D / Contour",
[
"PLOT_SURFACE_CMAP",
"PLOT_CONTOUR_LEVELS",
"PLOT_GRID_ALPHA",
"PLOT_SURFACE_ALPHA",
"PLOT_COLORBAR_SHRINK",
],
),
(
"plot_animation",
"Plot Animation",
[
"PLOT_ANIMATION_LINE_WIDTH",
"PLOT_VLINES_LINE_WIDTH",
"PLOT_VLINES_ALPHA",
"PLOT_ANIMATION_Y_MARGIN",
"ANIMATION_MAX_FPS",
],
),
(
"solver",
"Solver Defaults",
[
"SOLVER_MAX_STEP",
"SOLVER_RTOL",
"SOLVER_ATOL",
"SOLVER_NUM_POINTS",
],
),
(
"logging",
"Logging & Update",
[
"LOG_LEVEL",
"LOG_FILE",
"LOG_CONSOLE",
"CHECK_UPDATES",
"UPDATE_CHECK_INTERVAL_DAYS",
"CHECK_UPDATES_FORCE",
"UPDATE_CHECK_URL",
],
),
]
[docs]
class ConfigDialog:
"""Scrollable form to edit all ``.env`` configuration values.
After calling, inspect ``self.accepted`` to know if the user saved.
Args:
parent: Parent window.
"""
def __init__(self, parent: tk.Tk | tk.Toplevel) -> None:
self.parent = parent
self.accepted = False
self.win = tk.Toplevel(parent)
self.win.title("Configuration")
bg: str = get_env_from_schema("UI_BACKGROUND")
self.win.configure(bg=bg)
self._vars: dict[str, tk.StringVar | tk.BooleanVar] = {}
self._desc_labels: list[ttk.Label] = []
self._build_ui()
fit_and_center(self.win, min_width=800, min_height=700)
make_modal(self.win, parent)
def _build_ui(self) -> None:
pad: int = get_env_from_schema("UI_PADDING")
bg: str = get_env_from_schema("UI_BACKGROUND")
current = get_current_env_values()
# --- Fixed bottom button bar (packed FIRST so it stays at bottom) ---
btn_frame = ttk.Frame(self.win)
btn_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=pad, pady=pad)
hint = ttk.Label(
btn_frame,
text="The application will restart after saving.",
style="Small.TLabel",
anchor=tk.CENTER,
)
hint.pack(fill=tk.X, pady=(0, pad // 2))
btn_inner = ttk.Frame(btn_frame)
btn_inner.pack()
btn_save = ttk.Button(btn_inner, text="Save", command=self._on_save)
btn_save.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([[btn_save, btn_cancel]])
# --- Scrollable area ---
self._scroll = ScrollableFrame(self.win)
self._scroll.apply_bg(bg)
self._scroll.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
form = self._scroll.inner
form.configure(padding=pad)
ttk.Label(form, text="Configuration", style="Title.TLabel").pack(
anchor=tk.W,
pady=(0, pad),
)
# --- Collapsible sections ---
first_section = True
for _section_id, section_title, keys in _SECTION_ORDER:
self._add_section(form, section_title, keys, current, pad, expanded=first_section)
first_section = False
self._scroll.bind_new_children()
bind_wraplength(form, self._desc_labels, pad=60, min_wrap=200)
btn_save.focus_set()
def _add_section(
self,
parent: ttk.Frame,
title: str,
keys: list[str],
current: dict[str, str],
pad: int,
*,
expanded: bool = False,
) -> None:
"""Add a complete collapsible section with its fields."""
section = CollapsibleSection(
parent,
self._scroll,
title,
expanded=expanded,
pad=pad,
)
section.content.configure(padding=(16, 4, 4, 4))
for key in keys:
item = SCHEMA_BY_KEY.get(key)
if item is None:
continue
self._add_field(section.content, item, current)
def _add_field(self, parent: ttk.Frame, item: dict[str, Any], current: dict[str, str]) -> None:
key = item["key"]
cast_type = item["cast_type"]
val = current.get(key, str(item["default"]))
desc_text = item.get("description", "")
row = ttk.Frame(parent)
row.pack(fill=tk.X, pady=2)
ttk.Label(row, text=key, width=28, anchor=tk.W).pack(side=tk.LEFT)
if cast_type is bool:
bvar = tk.BooleanVar(value=val.lower() in ("true", "1", "yes"))
cb = ttk.Checkbutton(row, variable=bvar)
cb.pack(side=tk.LEFT)
self._vars[key] = bvar
elif "options" in item:
svar = tk.StringVar(value=val)
combo = ttk.Combobox(
row,
textvariable=svar,
values=list(item["options"]),
state="readonly",
width=22,
font=get_font(),
)
combo.pack(side=tk.LEFT)
self._vars[key] = svar
else:
svar = tk.StringVar(value=val)
entry = ttk.Entry(row, textvariable=svar, width=25, font=get_font())
entry.pack(side=tk.LEFT)
self._vars[key] = svar
if desc_text:
desc = ttk.Label(parent, text=desc_text, style="ConfigDesc.TLabel", justify=tk.LEFT)
desc.pack(anchor=tk.W, padx=(12, 0), pady=(0, 4))
self._desc_labels.append(desc)
def _on_save(self) -> None:
"""Write the edited values to ``.env`` and flag accepted."""
values: dict[str, str] = {}
for item in ENV_SCHEMA:
key = item["key"]
var = self._vars.get(key)
if var is None:
continue
if isinstance(var, tk.BooleanVar):
values[key] = "true" if var.get() else "false"
else:
values[key] = var.get()
try:
write_env_file(get_env_path(), values)
logger.info("Configuration saved to .env")
self.accepted = True
self.win.destroy()
except Exception as exc:
logger.error("Failed to save .env: %s", exc, exc_info=True)
messagebox.showerror("Error", f"Could not save: {exc}", parent=self.win)