Source code for frontend.ui_dialogs.config_dialog

"""Configuration dialog for editing .env settings."""

from typing import Any, List, Tuple, Union
from tkinter import (
    Toplevel,
    Frame,
    StringVar,
    BooleanVar,
    Canvas,
    messagebox,
    ttk,
)

from config import (
    ENV_SCHEMA,
    UI_STYLE,
    apply_hover_to_children,
    get_current_env_values,
    get_entry_font,
    get_project_root,
    write_env_file,
)
from frontend.window_utils import place_window_centered
from i18n import t

_CONFIG_COLLAPSED = "\u25b6"
_CONFIG_EXPANDED = "\u25bc"


def _config_section_for_key(key: str) -> str:
    """
    Determine configuration section for an environment variable key.

    Groups environment variables into logical sections (language, ui, plot,
    font, paths, links, logging, other) based on key prefixes and names.

    Args:
        key: Environment variable key (e.g., ``'LANGUAGE'``, ``'UI_BG'``, ``'PLOT_DPI'``).

    Returns:
        Section identifier string (e.g., ``'language'``, ``'ui'``, ``'plot'``).
    """
    if key == "LANGUAGE":
        return "language"
    if key.startswith("UI_"):
        return "ui"
    if key.startswith("PLOT_") or key == "DPI":
        return "plot"
    if key.startswith("FONT_"):
        return "font"
    if key.startswith("FILE_"):
        return "paths"
    if key == "DONATIONS_URL":
        return "links"
    if key in ("CHECK_UPDATES", "CHECK_UPDATES_FORCE", "UPDATE_CHECK_URL"):
        return "updates"
    if key.startswith("LOG_"):
        return "logging"
    return "other"


def _build_config_sections() -> List[Tuple[str, List[dict]]]:
    """
    Group ENV_SCHEMA items by section, preserving order of first occurrence.

    Organizes environment variable schema items into sections based on their
    keys, maintaining the order in which sections first appear.

    Returns:
        List of tuples ``(section_name, [schema_items])``, ordered by first
        occurrence of each section in ``ENV_SCHEMA``.
    """
    order: List[str] = []
    sections_dict: dict[str, List[dict]] = {}
    for item in ENV_SCHEMA:
        section = _config_section_for_key(item["key"])
        if section not in sections_dict:
            sections_dict[section] = []
            order.append(section)
        sections_dict[section].append(item)
    return [(sec, sections_dict[sec]) for sec in order]


[docs] def show_config_dialog(parent_window: Any) -> bool: """ Show configuration dialog to edit .env fields. Pre-fills with current env values (or defaults). On Accept, writes .env and returns True so the caller can restart the app. On Cancel returns False. Args: parent_window: Parent Tkinter window (``Tk`` or ``Toplevel``). Returns: ``True`` if user accepted and ``.env`` was written (caller should restart). ``False`` if user cancelled. """ config_level = Toplevel() config_level.title(t("config.title")) config_level.configure(background=UI_STYLE["bg"]) config_level.grab_set() current = get_current_env_values() result_accepted = False main_frame = ttk.Frame(config_level, padding=UI_STYLE["border_width"]) main_frame.pack(padx=UI_STYLE["padding"], pady=6, fill="both", expand=True) canvas = Canvas( main_frame, bg=UI_STYLE["bg"], highlightthickness=0, ) scrollbar = ttk.Scrollbar(main_frame, orient="vertical", command=canvas.yview) inner = ttk.Frame(canvas) inner.bind( "<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")), ) canvas_window = canvas.create_window((0, 0), window=inner, anchor="nw") canvas.configure(yscrollcommand=scrollbar.set) def _on_frame_configure(_event: Any) -> None: canvas.configure(scrollregion=canvas.bbox("all")) def _on_canvas_configure(event: Any) -> None: canvas.itemconfig(canvas_window, width=event.width) inner.bind("<Configure>", _on_frame_configure) canvas.bind("<Configure>", _on_canvas_configure) def _on_mousewheel(event: Any) -> str: if hasattr(event, "delta") and event.delta != 0: canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") elif event.num == 5: canvas.yview_scroll(1, "units") elif event.num == 4: canvas.yview_scroll(-1, "units") return "break" def _bind_mousewheel_recursive(widget: Any) -> None: widget.bind("<MouseWheel>", _on_mousewheel) widget.bind("<Button-4>", _on_mousewheel) widget.bind("<Button-5>", _on_mousewheel) for child in widget.winfo_children(): _bind_mousewheel_recursive(child) canvas.bind("<MouseWheel>", _on_mousewheel) canvas.bind("<Button-4>", _on_mousewheel) canvas.bind("<Button-5>", _on_mousewheel) inner.bind("<MouseWheel>", _on_mousewheel) inner.bind("<Button-4>", _on_mousewheel) inner.bind("<Button-5>", _on_mousewheel) scrollbar.pack(side="right", fill="y") canvas.pack(side="left", fill="both", expand=True) entries: dict[str, Tuple[str, Union[BooleanVar, StringVar]]] = {} config_desc_labels: List[ttk.Label] = [] first_section_ref: List[Tuple[Any, Any, int]] = [] row_index = 0 for section, section_items in _build_config_sections(): header_frame = ttk.Frame(inner, style="ConfigSectionHeader.TFrame") header_frame.bind("<Enter>", lambda e: header_frame.configure(cursor="hand2")) header_frame.bind("<Leave>", lambda e: header_frame.configure(cursor="")) arrow_var = StringVar(value=_CONFIG_COLLAPSED) arrow_lbl = ttk.Label( header_frame, textvariable=arrow_var, style="ConfigSectionHeader.TLabel" ) arrow_lbl.pack(side="left", padx=(10, 6), pady=8) title_lbl = ttk.Label( header_frame, text=t(f"config.section_{section}"), style="ConfigSectionHeader.TLabel", ) title_lbl.pack(side="left", pady=8) header_frame.grid( row=row_index, column=0, columnspan=2, sticky="ew", padx=0, pady=(14, 0) ) row_index += 1 content_wrapper = ttk.Frame(inner, style="ConfigSectionContent.TFrame") content_wrapper.grid( row=row_index, column=0, columnspan=2, sticky="ew", padx=0, pady=0 ) content_wrapper.grid_remove() if not first_section_ref: first_section_ref.append((content_wrapper, arrow_var, row_index)) row_index += 1 accent_line = Frame( content_wrapper, width=4, bg=UI_STYLE["widget_hover_bg"], highlightthickness=0, ) accent_line.pack(side="left", fill="y") accent_line.pack_propagate(False) section_frame = ttk.Frame(content_wrapper) section_frame.pack( side="left", fill="both", expand=True, padx=(6, 0), pady=(4, 12) ) sub_row = 0 for item in section_items: key = item["key"] default = item["default"] cast_type = item["cast_type"] label_text = t(f"config.label_{key}") ttk.Label(section_frame, text=label_text).grid( row=sub_row, column=0, sticky="w", padx=4, pady=2 ) desc_text = t(f"config.desc_{key}") desc_lbl = ttk.Label( section_frame, text=desc_text, wraplength=600, justify="left", style="ConfigOptionDesc.TLabel", ) desc_lbl.grid( row=sub_row + 1, column=0, columnspan=2, sticky="w", padx=12, pady=(0, 6), ) config_desc_labels.append(desc_lbl) sub_row += 2 if cast_type is bool: var = BooleanVar( value=current.get(key, "false").lower() in ("true", "1", "yes") ) cb = ttk.Checkbutton(section_frame, variable=var) cb.grid(row=sub_row, column=0, columnspan=2, sticky="w", padx=4, pady=2) entries[key] = ("check", var) else: raw_val = current.get(key, str(default)) opts = item.get("options") if opts: opts_list = list(opts) if raw_val in opts_list: sv = StringVar(value=raw_val) else: normalized = ( str(raw_val).upper() if key == "LOG_LEVEL" else str(raw_val).lower() ) sv = StringVar( value=normalized if normalized in opts_list else str(default) ) combo = ttk.Combobox( section_frame, textvariable=sv, values=opts_list, state="readonly", width=UI_STYLE["entry_width"], font=get_entry_font(), ) combo.grid( row=sub_row, column=0, columnspan=2, sticky="ew", padx=4, pady=2 ) entries[key] = ("entry", sv) else: sv = StringVar(value=current.get(key, str(default))) ent = ttk.Entry( section_frame, textvariable=sv, width=UI_STYLE["entry_width"], font=get_entry_font(), ) ent.grid( row=sub_row, column=0, columnspan=2, sticky="ew", padx=4, pady=2 ) entries[key] = ("entry", sv) sub_row += 1 section_frame.columnconfigure(0, weight=1) def _make_toggle( content: ttk.Frame, arrow: StringVar, header_fr: ttk.Frame, arrow_label: ttk.Label, title_label: ttk.Label, ) -> None: def toggle() -> None: if content.winfo_viewable(): content.grid_remove() arrow.set(_CONFIG_COLLAPSED) else: content.grid() arrow.set(_CONFIG_EXPANDED) config_level.update_idletasks() canvas.configure(scrollregion=canvas.bbox("all")) config_level.after_idle( lambda: canvas.configure(scrollregion=canvas.bbox("all")) ) for w in (header_fr, arrow_label, title_label): w.bind("<Button-1>", lambda e: toggle()) _make_toggle(content_wrapper, arrow_var, header_frame, arrow_lbl, title_lbl) if first_section_ref: first_content, first_arrow, first_row = first_section_ref[0] first_content.grid( row=first_row, column=0, columnspan=2, sticky="ew", padx=0, pady=0 ) first_arrow.set(_CONFIG_EXPANDED) inner.columnconfigure(0, weight=1) _bind_mousewheel_recursive(inner) apply_hover_to_children(inner) def _update_config_wraplength(_e: Any = None) -> None: w = inner.winfo_width() if w > 80: wrap = max(120, w - 48) for lbl in config_desc_labels: lbl.configure(wraplength=wrap) inner.bind("<Configure>", _update_config_wraplength) def on_accept() -> None: nonlocal result_accepted values: dict[str, str] = {} for item in ENV_SCHEMA: key = item["key"] cast_type = item["cast_type"] default = item["default"] w = entries.get(key) if w is None: continue if cast_type is bool: _, var = w values[key] = "true" if var.get() else "false" else: _, sv = w raw = sv.get().strip() if not raw: values[key] = str(default) elif cast_type in (int, float): try: (int if cast_type is int else float)(raw) values[key] = raw except ValueError: values[key] = str(default) else: values[key] = raw env_path = get_project_root() / ".env" try: write_env_file(env_path, values) result_accepted = True except OSError: messagebox.showerror( t("error.critical_error"), t("config.save_error", path=str(env_path)), ) return config_level.destroy() def on_cancel() -> None: config_level.destroy() restart_hint = ttk.Label( config_level, text=t("config.restart_hint"), justify="center", ) restart_hint.pack(pady=(0, 4)) btn_frame = ttk.Frame(config_level) btn_frame.pack(padx=UI_STYLE["padding"], pady=UI_STYLE["padding"]) ttk.Button( btn_frame, text=t("dialog.accept"), command=on_accept, style="Primary.TButton", width=UI_STYLE["button_width"], ).pack(side="left", padx=(0, UI_STYLE["padding"])) ttk.Button( btn_frame, text=t("dialog.cancel"), command=on_cancel, style="Danger.TButton", width=UI_STYLE["button_width"], ).pack(side="left") place_window_centered( config_level, 760, 800, max_width_ratio=0.58, max_height_ratio=0.85, ) config_level.resizable(False, False) config_level.update_idletasks() _update_config_wraplength() def _focus_first_focusable(widget: Any) -> bool: """Set focus to the first focusable child; return True if found.""" c = widget.winfo_class() if c in ( "Entry", "TEntry", "TCombobox", "TCheckbutton", "TRadiobutton", "TButton", "Button", ): widget.focus_set() return True for child in widget.winfo_children(): if _focus_first_focusable(child): return True return False config_level.after(50, lambda: _focus_first_focusable(config_level)) config_level.protocol("WM_DELETE_WINDOW", on_cancel) parent_window.wait_window(config_level) return result_accepted