SXXXXXXX_ProjectUtility/gui/main_window.py
2025-04-29 10:09:19 +02:00

765 lines
41 KiB
Python

# ProjectUtility/gui/main_window.py
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext, filedialog
import logging
import queue
import threading
import os
import sys
import json
from typing import Dict, Any, Optional, List, Tuple
# --- INIZIO BLOCCO IMPORT CORRETTO E ROBUSTO ---
# Determine the application's root directory more robustly
# Assumes gui is one level down from the project root where ProjectUtility.py resides
_project_root = None
try:
# Path to the gui directory itself
_gui_dir = os.path.dirname(os.path.abspath(__file__))
# Path to the parent directory (should be the project root)
_project_root = os.path.dirname(_gui_dir)
# Add project root to sys.path if it's not already there
# This makes imports like 'from core...' work regardless of how the script is run
if _project_root not in sys.path:
sys.path.insert(0, _project_root)
logging.getLogger(__name__).debug(f"Added project root to sys.path: {_project_root}")
else:
logging.getLogger(__name__).debug(f"Project root already in sys.path: {_project_root}")
except Exception as e:
logging.getLogger(__name__).critical(f"Failed to determine project root directory: {e}", exc_info=True)
# Cannot proceed without project root for imports
# Raising an error here might be better than continuing with broken imports
raise RuntimeError("Could not determine project structure for imports.") from e
# Now perform the imports using absolute paths from the project root
try:
# These imports should now work because _project_root is in sys.path
from core.models import ToolInfo, ToolParameter
from core.tool_discovery import discover_tools
from gui.process_worker import ProcessWorker # Absolute import from project root
TOOL_DISCOVERY_ENABLED = True
PROCESS_WORKER_ENABLED = True
logging.getLogger(__name__).debug("Core and GUI modules imported successfully.")
except ImportError as e:
# This block now ONLY catches genuine import errors AFTER path setup
logging.getLogger(__name__).critical(f"ImportError after setting path. Check module existence/dependencies. Error: {e}", exc_info=True)
TOOL_DISCOVERY_ENABLED = False
PROCESS_WORKER_ENABLED = False
# Define dummy classes if needed for static analysis or basic structure
from collections import namedtuple
ToolParameter = namedtuple("ToolParameter", ["name", "label", "type", "required", "default", "description", "options"])
ToolInfo = namedtuple("ToolInfo", ["id", "display_name", "description", "command", "working_dir", "parameters", "version"])
class ProcessWorker: # Dummy ProcessWorker
def __init__(self, *args, **kwargs): pass
def run(self): pass
def terminate(self): pass
class DummyToolInfo: display_name = "Dummy (Import Failed)"
tool_info = DummyToolInfo()
except Exception as e:
# Catch any other unexpected error during the import phase
logging.getLogger(__name__).critical(f"Unexpected error during imports: {e}", exc_info=True)
TOOL_DISCOVERY_ENABLED = False
PROCESS_WORKER_ENABLED = False
# Define dummies again just in case
from collections import namedtuple
ToolParameter = namedtuple("ToolParameter", ["name", "label", "type", "required", "default", "description", "options"])
ToolInfo = namedtuple("ToolInfo", ["id", "display_name", "description", "command", "working_dir", "parameters", "version"])
class ProcessWorker: pass
# --- FINE BLOCCO IMPORT CORRETTO E ROBUSTO ---
# --- Constants ---
MAX_OUTPUT_LINES = 1000
# Use the calculated project root for config directory if available
APP_ROOT_DIR = _project_root if _project_root else os.getcwd() # Fallback to current dir if root calc failed
CONFIG_DIR = os.path.join(APP_ROOT_DIR, "config")
STATE_FILENAME = os.path.join(CONFIG_DIR, "tool_state.json")
# --- Inizio Classe MainWindow ---
class MainWindow:
"""
The main application window for ProjectUtility using Tkinter.
Manages the display of available tools, dynamic tool parameter inputs,
and process output. Handles launching tools in background threads and
persists last used parameters.
"""
def __init__(self, root: tk.Tk) -> None:
"""Initializes the main application window."""
self.root = root
self.logger = logging.getLogger(__name__)
self.gui_queue = queue.Queue()
self.available_tools: dict[str, ToolInfo] = {}
self.tools_in_listbox: list[str] = []
self.selected_tool_id: str | None = None
self.running_workers: dict[str, ProcessWorker] = {}
# Key: parameter name (ToolParameter.name), Value: tuple (widget_instance, tk_variable)
self.current_parameter_widgets: Dict[str, Tuple[tk.Widget, tk.Variable]] = {}
# Load initial tool state (last used parameters)
self.tool_state: Dict[str, Dict[str, Any]] = self._load_tool_state()
# Setup UI components
self._setup_window()
self._setup_ui_styles() # Setup styles first
self._setup_ui()
self._load_tools() # Load tools after UI is ready
self._start_queue_processing() # Start checking for messages from workers
# Make window visible only after everything is set up
self.root.deiconify()
self.logger.info("MainWindow initialized and displayed.")
def _load_tool_state(self) -> Dict[str, Dict[str, Any]]:
"""Loads the last used parameter values from the state file."""
if not os.path.isfile(STATE_FILENAME):
self.logger.info(f"State file not found ({STATE_FILENAME}). Starting with empty state.")
return {}
try:
with open(STATE_FILENAME, 'r', encoding='utf-8') as f:
state_data = json.load(f)
if not isinstance(state_data, dict):
self.logger.warning(f"Invalid format in state file ({STATE_FILENAME}). Expected dictionary. Resetting state.")
return {}
# Optional: Add validation for the structure of state_data if needed
self.logger.info(f"Successfully loaded tool state from {STATE_FILENAME}")
return state_data
except json.JSONDecodeError as e:
self.logger.error(f"Failed to parse state file ({STATE_FILENAME}): {e}. Resetting state.")
return {}
except Exception as e:
self.logger.exception(f"An unexpected error occurred loading state file {STATE_FILENAME}. Resetting state.")
return {}
def _save_tool_state(self, tool_id: str, parameters_used: Dict[str, Any]) -> None:
"""Saves the used parameters for a specific tool to the state file."""
if not tool_id:
self.logger.warning("Attempted to save state without a valid tool_id.")
return
self.logger.info(f"Saving state for tool '{tool_id}' with parameters: {parameters_used}")
# Ensure the config directory exists
try:
os.makedirs(CONFIG_DIR, exist_ok=True)
except OSError as e:
self.logger.error(f"Failed to create config directory '{CONFIG_DIR}': {e}. Cannot save state.")
return
# Update the in-memory state first
if tool_id not in self.tool_state:
self.tool_state[tool_id] = {}
self.tool_state[tool_id].update(parameters_used)
# Write the entire state back to the file atomically (best effort)
temp_filename = STATE_FILENAME + ".tmp"
try:
with open(temp_filename, 'w', encoding='utf-8') as f:
json.dump(self.tool_state, f, indent=4, ensure_ascii=False)
os.replace(temp_filename, STATE_FILENAME) # Atomic rename
self.logger.debug(f"Tool state saved successfully to {STATE_FILENAME}")
except (TypeError, ValueError) as e: # Catch JSON serialization errors
self.logger.error(f"Failed to serialize state to JSON for {STATE_FILENAME}: {e}. State may not be saved correctly.", exc_info=True)
if os.path.exists(temp_filename): os.remove(temp_filename)
except (IOError, OSError) as e: # Catch file write/rename errors
self.logger.error(f"Failed to write or replace state file {STATE_FILENAME}: {e}. State may not be saved.", exc_info=True)
if os.path.exists(temp_filename): os.remove(temp_filename)
except Exception as e:
self.logger.exception(f"An unexpected error occurred saving state file {STATE_FILENAME}.")
if os.path.exists(temp_filename): os.remove(temp_filename)
def _clear_parameter_widgets_only(self) -> None:
"""Clears only the parameter widgets from the controls frame and the widget dictionary."""
self.logger.debug("Clearing parameter widgets area.")
self.current_parameter_widgets.clear() # Clear widget references
# Check if frame exists before accessing children
if hasattr(self, 'tool_controls_frame') and self.tool_controls_frame.winfo_exists():
# Destroy existing widgets inside the frame, BUT KEEP the frame itself
# and potentially static elements like name/description labels if they are
# not destroyed/recreated every time.
# Let's destroy all children for simplicity now, assuming name/desc are recreated.
for widget in self.tool_controls_frame.winfo_children():
# Avoid destroying the placeholder if it's the only thing left
if widget != getattr(self, 'controls_placeholder_label', None):
widget.destroy()
# If the placeholder exists and isn't packed, pack it again as default state
if hasattr(self, 'controls_placeholder_label') and self.controls_placeholder_label.winfo_exists():
# Check if frame is empty before packing placeholder
if not self.tool_controls_frame.winfo_children() or \
(len(self.tool_controls_frame.winfo_children()) == 1 and \
self.tool_controls_frame.winfo_children()[0] == self.controls_placeholder_label):
self.controls_placeholder_label.pack(padx=5, pady=20) # Repack if needed
else: # Recreate placeholder if it was destroyed somehow
self.controls_placeholder_label = ttk.Label(self.tool_controls_frame, text="Select a tool from the list.", style="Placeholder.TLabel")
self.controls_placeholder_label.pack(padx=5, pady=20)
def _setup_window(self) -> None:
"""Configures the main Tkinter window."""
self.root.title("Project Utility")
self.root.minsize(700, 500)
self.root.geometry("900x700")
self.root.protocol("WM_DELETE_WINDOW", self._on_close) # Handle close button
self.logger.debug("Main window configured.")
def _setup_ui_styles(self) -> None:
"""Sets up custom ttk styles used in the UI."""
style = ttk.Style(self.root)
try:
available_themes = style.theme_names()
for theme in ['clam', 'alt', 'default']:
if theme in available_themes:
style.theme_use(theme)
self.logger.debug(f"Using ttk theme: {theme}")
break
except tk.TclError:
self.logger.warning("Could not set ttk theme.")
style.configure("Italic.TLabel", font=("Segoe UI", 9, "italic"))
style.configure("Placeholder.TLabel", foreground="gray", font=("Segoe UI", 9, "italic"))
# Use explicit background color lookup for frames inside canvas/labelframe
try:
frame_bg = style.lookup('TFrame', 'background')
lf_bg = style.lookup('TLabelFrame', 'background')
except tk.TclError:
frame_bg = "#f0f0f0" # Fallback background
lf_bg = "#f0f0f0"
style.configure("ParamContainer.TFrame", background=lf_bg) # Match LabelFrame background
style.configure("Preview.TFrame", background="#ffffff")
style.configure("Preview.TLabel", background="#ffffff")
def _setup_ui(self) -> None:
"""Creates and arranges the main UI widgets."""
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
main_frame = ttk.Frame(self.root, padding="10")
main_frame.grid(row=0, column=0, sticky="nsew")
main_frame.columnconfigure(1, weight=3)
main_frame.rowconfigure(0, weight=1)
# --- Left Pane: Tool List ---
tool_list_frame = ttk.Frame(main_frame, padding=(0, 0, 5, 0))
tool_list_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 5))
tool_list_frame.rowconfigure(1, weight=1)
ttk.Label(tool_list_frame, text="Available Tools:", font="-weight bold").grid(row=0, column=0, sticky="w", pady=(0, 5))
self.tool_listbox = tk.Listbox(tool_list_frame, exportselection=False, width=25)
self.tool_listbox.grid(row=1, column=0, sticky="nsew")
listbox_scrollbar = ttk.Scrollbar(tool_list_frame, orient=tk.VERTICAL, command=self.tool_listbox.yview)
listbox_scrollbar.grid(row=1, column=1, sticky="ns")
self.tool_listbox.config(yscrollcommand=listbox_scrollbar.set)
self.tool_listbox.bind("<<ListboxSelect>>", self._on_tool_selected)
# --- Right Pane: Details and Output ---
right_pane = ttk.Frame(main_frame)
right_pane.grid(row=0, column=1, sticky="nsew")
right_pane.rowconfigure(1, weight=1) # Output expands
right_pane.columnconfigure(0, weight=1) # Pane expands
# --- Tool Controls/Options Area (Scrollable) ---
controls_outer_frame = ttk.LabelFrame(right_pane, text="Tool Options", padding="5") # Use LabelFrame for border
controls_outer_frame.grid(row=0, column=0, sticky="nsew", pady=(0, 10))
controls_outer_frame.rowconfigure(0, weight=0) # Don't let canvas row expand excessively
controls_outer_frame.columnconfigure(0, weight=1) # Canvas expands horizontally
controls_canvas = tk.Canvas(controls_outer_frame, borderwidth=0, highlightthickness=0)
controls_scrollbar = ttk.Scrollbar(controls_outer_frame, orient="vertical", command=controls_canvas.yview)
# Frame inside canvas holding the actual parameter widgets
self.tool_controls_frame = ttk.Frame(controls_canvas, style='ParamContainer.TFrame', padding="5")
self.tool_controls_frame.bind("<Configure>", lambda e: controls_canvas.configure(scrollregion=controls_canvas.bbox("all")))
controls_canvas_window = controls_canvas.create_window((0, 0), window=self.tool_controls_frame, anchor="nw")
controls_canvas.configure(yscrollcommand=controls_scrollbar.set)
controls_canvas.bind('<Configure>', lambda e: self.root.after(10, lambda: controls_canvas.itemconfig(controls_canvas_window, width=e.width))) # Added delay for width adjustment
controls_canvas.grid(row=0, column=0, sticky="nsew")
controls_scrollbar.grid(row=0, column=1, sticky="ns")
# Initial placeholder inside tool_controls_frame
self.controls_placeholder_label = ttk.Label(self.tool_controls_frame, text="Select a tool from the list.", style="Placeholder.TLabel")
self.controls_placeholder_label.pack(padx=5, pady=20)
# --- Output Area ---
output_frame = ttk.LabelFrame(right_pane, text="Output Log", padding="5")
output_frame.grid(row=1, column=0, sticky="nsew", pady=(0, 5))
output_frame.rowconfigure(0, weight=1)
output_frame.columnconfigure(0, weight=1)
self.output_text = scrolledtext.ScrolledText(output_frame, wrap=tk.WORD, state=tk.DISABLED, height=15, relief=tk.SUNKEN, borderwidth=1)
self.output_text.grid(row=0, column=0, sticky="nsew")
self.output_text.tag_config("error", foreground="#D63031") # Use specific colors
self.output_text.tag_config("info", foreground="#0984E3")
self.output_text.tag_config("success", foreground="#00B894")
self.output_text.tag_config("command", foreground="#6C5CE7", font="-weight bold")
self.output_text.tag_config("run_id", foreground="#636E72", font="-size 8")
# --- Run Button Area ---
button_frame = ttk.Frame(right_pane, padding=(0, 5, 0, 0))
button_frame.grid(row=2, column=0, sticky="ew")
button_frame.columnconfigure(0, weight=1)
self.run_button = ttk.Button(button_frame, text="Run Tool", command=self._launch_tool, state=tk.DISABLED)
self.run_button.grid(row=0, column=1, sticky="e", padx=5)
self.logger.debug("UI widgets created.")
def _load_tools(self) -> None:
"""Discovers tools and populates the tool listbox."""
self.logger.info("Loading available tools...")
self.tool_listbox.delete(0, tk.END)
self.available_tools.clear()
self.tools_in_listbox.clear()
if self.selected_tool_id:
self._clear_tool_controls()
else:
self.run_button.config(state=tk.DISABLED)
if not TOOL_DISCOVERY_ENABLED:
messagebox.showwarning("Tool Discovery Disabled", "Could not load tool discovery module. Tool list is empty.")
self._clear_tool_controls()
if hasattr(self, 'controls_placeholder_label') and self.controls_placeholder_label.winfo_exists():
self.controls_placeholder_label.config(text="Tool Discovery Module Missing.")
return
try:
tools_dir = os.path.join(APP_ROOT_DIR, "tools")
if not os.path.isdir(tools_dir):
raise FileNotFoundError(f"Tools directory does not exist: {tools_dir}")
self.available_tools = discover_tools(tools_dir)
if not self.available_tools:
self.logger.warning(f"No valid tools found in '{tools_dir}'.")
self._clear_tool_controls()
if hasattr(self, 'controls_placeholder_label') and self.controls_placeholder_label.winfo_exists():
self.controls_placeholder_label.config(text="No tools found in 'tools' directory.")
return
sorted_tool_items = sorted(self.available_tools.items(), key=lambda item: item[1].display_name)
for tool_id, tool_info in sorted_tool_items:
if isinstance(tool_info, ToolInfo):
self.tool_listbox.insert(tk.END, tool_info.display_name)
self.tools_in_listbox.append(tool_id)
self.logger.debug(f"Loaded tool: {tool_info.display_name} (ID: {tool_id})")
self.logger.info(f"Finished loading {len(self.tools_in_listbox)} tools.")
except FileNotFoundError as e:
self.logger.error(f"Tools directory error: {e}")
messagebox.showerror("Error Loading Tools", f"Tools directory not found or inaccessible:\n{e}")
self._clear_tool_controls()
if hasattr(self, 'controls_placeholder_label') and self.controls_placeholder_label.winfo_exists():
self.controls_placeholder_label.config(text="Error: Tools directory not found.")
except Exception as e:
self.logger.exception("An unexpected error occurred during tool discovery.")
messagebox.showerror("Error Loading Tools", f"An unexpected error occurred while loading tools:\n{e}")
self._clear_tool_controls()
if hasattr(self, 'controls_placeholder_label') and self.controls_placeholder_label.winfo_exists():
self.controls_placeholder_label.config(text="Error loading tools.")
def _clear_tool_controls(self) -> None:
"""Removes all widgets from the tool controls frame and adds the placeholder."""
self.logger.debug("Clearing tool controls area.")
self.current_parameter_widgets.clear()
# Check if frame exists before accessing children
if hasattr(self, 'tool_controls_frame') and self.tool_controls_frame.winfo_exists():
for widget in self.tool_controls_frame.winfo_children():
widget.destroy()
# Re-add the placeholder label if it doesn't exist or was destroyed
if not hasattr(self, 'controls_placeholder_label') or not self.controls_placeholder_label.winfo_exists():
self.controls_placeholder_label = ttk.Label(self.tool_controls_frame, text="Select a tool from the list.", style="Placeholder.TLabel")
# Ensure it's packed
self.controls_placeholder_label.pack(padx=5, pady=20) # Pack ensures it's managed
# Reset selection state and disable run button
self.selected_tool_id = None
if hasattr(self, 'run_button'):
self.run_button.config(state=tk.DISABLED)
def _on_tool_selected(self, event: tk.Event) -> None:
"""Handles the selection change in the tool listbox and builds parameter UI."""
# Check if listbox still exists (might be destroyed during shutdown)
if not self.tool_listbox.winfo_exists():
return
selected_indices = self.tool_listbox.curselection()
if not selected_indices:
return # No selection
selected_index = selected_indices[0]
if not (0 <= selected_index < len(self.tools_in_listbox)):
self.logger.warning(f"Invalid selected listbox index: {selected_index}.")
return # Index out of bounds
new_selected_tool_id = self.tools_in_listbox[selected_index]
if new_selected_tool_id == self.selected_tool_id:
return # Selection didn't change
self.selected_tool_id = new_selected_tool_id
self.logger.info(f"Tool selected: {self.selected_tool_id}")
selected_tool_info = self.available_tools.get(self.selected_tool_id)
if not selected_tool_info:
self.logger.error(f"Internal Error: Selected tool ID '{self.selected_tool_id}' not found in available_tools.")
self._clear_tool_controls()
return
# --- Rebuild Controls UI ---
last_used_params = self.tool_state.get(self.selected_tool_id, {})
self._clear_parameter_widgets_only()
# Remove placeholder if it exists
if hasattr(self, 'controls_placeholder_label') and self.controls_placeholder_label.winfo_exists():
self.controls_placeholder_label.pack_forget() # Unmap it
# Keep the widget instance around to reuse in _clear_tool_controls
# Add tool details
ttk.Label(self.tool_controls_frame, text=f"{selected_tool_info.display_name}", font="-weight bold").pack(anchor="w", pady=(0,5), padx=5)
ttk.Label(self.tool_controls_frame, text=selected_tool_info.description, wraplength=450).pack(anchor="w", pady=(0,10), padx=5)
# Add parameter widgets
if selected_tool_info.parameters:
ttk.Separator(self.tool_controls_frame, orient='horizontal').pack(fill='x', pady=10, padx=5)
param_container = ttk.Frame(self.tool_controls_frame, style='ParamContainer.TFrame')
param_container.pack(fill="x", expand=True, padx=5)
param_container.columnconfigure(1, weight=1)
for i, param_def in enumerate(selected_tool_info.parameters):
self._create_parameter_widget(param_container, i, param_def, last_used_params)
else:
ttk.Label(self.tool_controls_frame, text="This tool requires no parameters.", style="Italic.TLabel").pack(anchor="w", padx=5, pady=10)
# Update scroll region after packing everything
self.tool_controls_frame.update_idletasks()
canvas_widget = self.tool_controls_frame.master
if isinstance(canvas_widget, tk.Canvas):
canvas_widget.configure(scrollregion=canvas_widget.bbox("all"))
canvas_widget.yview_moveto(0) # Scroll to top
# Enable Run button
self.run_button.config(state=tk.NORMAL if PROCESS_WORKER_ENABLED else tk.DISABLED)
if not PROCESS_WORKER_ENABLED:
ttk.Label(self.tool_controls_frame, text="Process execution disabled.", foreground="orange").pack(anchor="w", pady=(10,0), padx=5)
def _create_parameter_widget(self, parent_frame: ttk.Frame, row_index: int,
param_def: ToolParameter,
last_used_values: Dict[str, Any]):
"""Creates the appropriate Label and input Widget for a ToolParameter."""
param_name = param_def.name
label_text = f"{param_def.label}{'*' if param_def.required else ''}"
widget_variable = None
input_widget = None
initial_value = last_used_values.get(param_name, param_def.default)
self.logger.debug(f"Param '{param_name}': LastUsed='{last_used_values.get(param_name)}', Default='{param_def.default}', Using='{initial_value}'")
label = ttk.Label(parent_frame, text=label_text)
label.grid(row=row_index, column=0, sticky="w", padx=(0, 10), pady=3)
param_type = param_def.type.lower()
try:
if param_type == "string":
widget_variable = tk.StringVar()
input_widget = ttk.Entry(parent_frame, textvariable=widget_variable, width=40)
if initial_value is not None: widget_variable.set(str(initial_value))
elif param_type == "integer":
widget_variable = tk.IntVar()
input_widget = ttk.Spinbox(parent_frame, from_=-2**31, to=2**31-1, textvariable=widget_variable, width=15)
default_int = 0
if initial_value is not None:
try: widget_variable.set(int(initial_value))
except (ValueError, TypeError): widget_variable.set(default_int); self.logger.warning(f"Invalid int value for {param_name}: '{initial_value}'")
else: widget_variable.set(default_int)
elif param_type == "float":
widget_variable = tk.DoubleVar()
input_widget = ttk.Spinbox(parent_frame, from_=-float('inf'), to=float('inf'), format="%.6g", textvariable=widget_variable, width=15) # Use %g for auto format
default_float = 0.0
if initial_value is not None:
try: widget_variable.set(float(initial_value))
except (ValueError, TypeError): widget_variable.set(default_float); self.logger.warning(f"Invalid float value for {param_name}: '{initial_value}'")
else: widget_variable.set(default_float)
elif param_type == "boolean":
widget_variable = tk.BooleanVar()
input_widget = ttk.Checkbutton(parent_frame, variable=widget_variable, style='TCheckbutton')
default_bool = False
if initial_value is not None:
if isinstance(initial_value, str):
widget_variable.set(initial_value.lower() in ['true', '1', 'yes', 'on'])
else: widget_variable.set(bool(initial_value))
else: widget_variable.set(default_bool)
elif param_type == "file" or param_type == "folder":
widget_variable = tk.StringVar()
entry_frame = ttk.Frame(parent_frame, style='ParamContainer.TFrame')
display_entry = ttk.Entry(entry_frame, textvariable=widget_variable, width=35, state='readonly')
browse_button = ttk.Button(entry_frame, text="Browse...", style='TButton',
command=lambda p=param_def, v=widget_variable: self._browse_path(p, v))
display_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
browse_button.pack(side=tk.LEFT, padx=(5, 0))
input_widget = entry_frame
if initial_value is not None: widget_variable.set(str(initial_value))
else:
self.logger.warning(f"Unsupported type '{param_def.type}' for '{param_name}'. Rendering as string.")
widget_variable = tk.StringVar()
input_widget = ttk.Entry(parent_frame, textvariable=widget_variable, width=40)
if initial_value is not None: widget_variable.set(str(initial_value))
except Exception as e:
self.logger.error(f"Error setting initial value for '{param_name}' (Val: '{initial_value}'): {e}", exc_info=True)
widget_variable = tk.StringVar(value="Error!")
input_widget = ttk.Entry(parent_frame, textvariable=widget_variable, width=40, state='disabled')
if input_widget is not None and widget_variable is not None:
input_widget.grid(row=row_index, column=1, sticky="ew", padx=0, pady=3)
self.current_parameter_widgets[param_name] = (input_widget, widget_variable)
# Don't log success here, too verbose. Log only errors/warnings.
else:
self.logger.error(f"Failed to create valid widget/variable pair for '{param_name}'")
def _browse_path(self, param_def: ToolParameter, var: tk.StringVar):
"""Opens a file or folder dialog based on parameter type."""
param_type = param_def.type.lower()
current_value = var.get()
initial_dir = os.path.dirname(current_value) if current_value and os.path.isdir(os.path.dirname(current_value)) else APP_ROOT_DIR
file_types = []
if param_def.options and isinstance(param_def.options.get("filter"), list):
try:
file_types = [(ft.get("name", "Files"), ft.get("pattern", "*.*"))
for ft in param_def.options["filter"] if isinstance(ft, dict)]
except Exception as e: self.logger.warning(f"Error parsing file filter for {param_def.name}: {e}")
if not file_types: file_types = [("All Files", "*.*")]
path = None
try:
if param_type == "file":
save_as = param_def.options and param_def.options.get("save_as", False)
dialog_func = filedialog.asksaveasfilename if save_as else filedialog.askopenfilename
path = dialog_func(
parent=self.root, title=f"Select Path for {param_def.label}",
initialdir=initial_dir, initialfile=os.path.basename(current_value) if current_value else "",
filetypes=file_types,
defaultextension = next( ( '.'+pat.split('.')[-1] for _, pat in file_types if '*.' in pat and '.' in pat), None ) if save_as else None
)
elif param_type == "folder":
path = filedialog.askdirectory(
parent=self.root, title=f"Select Folder for {param_def.label}",
initialdir=initial_dir if os.path.isdir(initial_dir) else APP_ROOT_DIR
)
else:
self.logger.error(f"_browse_path called for non-path type '{param_type}'"); return
if path: var.set(path); self.logger.debug(f"Path set for '{param_def.name}': {path}")
else: self.logger.debug(f"Path selection cancelled for '{param_def.name}'.")
except Exception as e:
self.logger.exception(f"Error opening file/folder dialog for '{param_def.name}'")
messagebox.showerror("Dialog Error", f"Could not open the browse dialog:\n{e}", parent=self.root)
def _launch_tool(self) -> None:
"""Gathers parameters, saves state, validates, and launches the selected tool."""
if not self.selected_tool_id or not PROCESS_WORKER_ENABLED:
messagebox.showwarning("Cannot Run Tool", "Please select a tool first.", parent=self.root)
return
selected_tool_info = self.available_tools.get(self.selected_tool_id)
if not selected_tool_info:
messagebox.showerror("Internal Error", "Could not get tool info.", parent=self.root); return
# --- Gather and Validate ---
parameters_to_pass = {}
validation_errors = []
for param_name, (widget, tk_var) in self.current_parameter_widgets.items():
param_def = next((p for p in selected_tool_info.parameters if p.name == param_name), None)
if not param_def: continue
try:
value = tk_var.get()
is_empty_str = isinstance(value, str) and not value.strip()
if param_def.required and is_empty_str:
validation_errors.append(f"- '{param_def.label}' is required.")
continue
# Add more validation as needed
parameters_to_pass[param_name] = value
except tk.TclError as e: validation_errors.append(f"- Invalid value for '{param_def.label}'.")
except Exception as e: validation_errors.append(f"- Error reading '{param_def.label}'."); self.logger.exception(f"Error reading param {param_name}")
if validation_errors:
messagebox.showerror("Invalid Parameters", "Please correct errors:\n\n" + "\n".join(validation_errors), parent=self.root)
return
# --- Save State ---
try: self._save_tool_state(self.selected_tool_id, parameters_to_pass)
except Exception: self.logger.exception("Error saving tool state.") # Log but continue
# --- Launch Process ---
if not PROCESS_WORKER_ENABLED: messagebox.showerror("Error", "Process Worker unavailable.", parent=self.root); return
try:
run_id = f"{self.selected_tool_id}_{os.urandom(4).hex()}"
self._append_output(f"[{run_id}] ", ("run_id",))
self._append_output(f"-- Starting: {selected_tool_info.display_name} --\n", ("info",))
worker = ProcessWorker(run_id, selected_tool_info, parameters_to_pass, self.gui_queue)
thread = threading.Thread(target=worker.run, daemon=True)
self.running_workers[run_id] = worker
thread.start()
self.logger.info(f"Worker thread started for run ID: {run_id}")
except Exception as e:
self.logger.exception(f"Failed to launch tool '{self.selected_tool_id}'")
messagebox.showerror("Launch Error", f"Could not start the tool process:\n{e}", parent=self.root)
self._append_output(f"ERROR: Failed to launch tool {selected_tool_info.display_name}\n", ("error",))
def _start_queue_processing(self) -> None:
"""Starts the periodic check of the GUI queue."""
self.logger.debug("Starting GUI queue processing loop.")
if self.root.winfo_exists():
self.root.after(100, self._process_queue)
def _process_queue(self) -> None:
"""Processes all available messages from the worker threads queue."""
try:
while True:
message = self.gui_queue.get_nowait()
self._handle_worker_message(message)
self.gui_queue.task_done()
except queue.Empty: pass # Normal case
except Exception: self.logger.exception("Error processing GUI queue message.")
finally:
if self.root.winfo_exists(): # Reschedule only if window still valid
self.root.after(100, self._process_queue)
else: self.logger.info("Root window gone, stopping queue processing.")
def _handle_worker_message(self, message: dict) -> None:
"""Handles a single message received from a worker thread."""
msg_type = message.get('type')
run_id = message.get('run_id', 'NO_RUN_ID') # Default if missing
data = message.get('data', '')
worker = self.running_workers.get(run_id)
tool_name = worker.tool_info.display_name if worker and hasattr(worker, 'tool_info') else "Unknown Tool"
run_id_tag = ("run_id",)
prefix = f"[{run_id}] "
# Handle based on message type
if msg_type == 'stdout':
self._append_output(prefix, run_id_tag); self._append_output(data)
elif msg_type == 'stderr':
self._append_output(prefix, run_id_tag); self._append_output(data, tag="error")
elif msg_type == 'status':
self.logger.info(f"Status [{run_id}]: {data}")
self._append_output(prefix, run_id_tag); self._append_output(f"[Status] {data}\n", tag="info")
elif msg_type == 'finished':
exit_code = message.get('exit_code', 'Unknown')
self.logger.info(f"Finished: Tool='{tool_name}', RunID='{run_id}', Code={exit_code}")
tag = "success" if exit_code == 0 else "error"
self._append_output(prefix, run_id_tag)
self._append_output(f"-- Finished: {tool_name} (Exit Code: {exit_code}) --\n", tag=tag)
if run_id in self.running_workers: del self.running_workers[run_id]
else: self.logger.warning(f"Finished message for untracked run_id: {run_id}")
# Update window title maybe? self.root.title(f"Project Utility ({len(self.running_workers)} running)")
elif msg_type == MSG_TYPE_PROGRESS:
value = message.get('value', 0.0); msg = message.get('message', '')
self.logger.info(f"Progress [{run_id}]: {value:.0%} - {msg}")
self._append_output(prefix, run_id_tag); self._append_output(f"[Progress {value:.0%}] {msg}\n", tag="info")
elif msg_type == MSG_TYPE_LOG:
level = message.get('level', 'info'); msg = message.get('message', '')
self.logger.info(f"Tool Log [{run_id}][{level}]: {msg}")
tag = "error" if level == "error" else "info"
self._append_output(prefix, run_id_tag); self._append_output(f"[Log:{level}] {msg}\n", tag=tag)
elif msg_type == MSG_TYPE_RESULT:
result_data = message.get('data', {})
self.logger.info(f"Result [{run_id}]: {result_data}")
try: result_str = json.dumps(result_data, indent=2) # Pretty print
except Exception: result_str = str(result_data) # Fallback
self._append_output(prefix, run_id_tag); self._append_output(f"[Result Data]:\n{result_str}\n", tag="success")
else:
self.logger.warning(f"Unknown message type '{msg_type}' from worker (RunID: {run_id}).")
self._append_output(prefix, run_id_tag); self._append_output(f"[Unknown Msg: {msg_type}] {data}\n", tag="error")
def _append_output(self, text: str, tag: Optional[str | Tuple[str, ...]] = None) -> None:
"""Appends text to the output ScrolledText widget safely, limiting total lines."""
if not hasattr(self, 'output_text') or not self.output_text.winfo_exists():
self.logger.warning("Output widget does not exist or destroyed, cannot append.")
return
try:
self.output_text.config(state=tk.NORMAL)
current_lines = int(self.output_text.index('end-1c').split('.')[0])
if current_lines > MAX_OUTPUT_LINES:
# Simple truncation: Delete first half when limit exceeded
lines_to_delete = current_lines - MAX_OUTPUT_LINES + (MAX_OUTPUT_LINES // 2)
if not hasattr(self, '_output_truncated_marker_added') or not self._output_truncated_marker_added:
self.output_text.insert("1.0", "[... Output Log Truncated ...]\n", ("info",))
self._output_truncated_marker_added = True
self.output_text.delete("2.0", f"{lines_to_delete + 1}.0") # Delete after marker
# Insert text
self.output_text.insert(tk.END, text, tag or ()) # Pass empty tuple if tag is None
self.output_text.see(tk.END) # Scroll to end
self.output_text.config(state=tk.DISABLED)
except tk.TclError as e: self.logger.error(f"TclError appending output: {e}", exc_info=True)
except Exception: self.logger.exception("Unexpected error appending output.")
def _on_close(self) -> None:
"""Handles the window close event."""
self.logger.info("Close requested. Checking for running processes.")
if self.running_workers:
num = len(self.running_workers)
msg = f"{num} tool(s) are running.\nExit and terminate them?"
if messagebox.askyesno("Confirm Exit", msg, parent=self.root):
self.logger.warning(f"User confirmed exit with {num} running process(es). Terminating.")
worker_ids = list(self.running_workers.keys()) # Copy keys
for worker_id in worker_ids:
worker = self.running_workers.get(worker_id)
if worker and hasattr(worker, 'terminate'):
self.logger.info(f"Requesting termination for {worker_id}")
try: worker.terminate()
except Exception: self.logger.exception(f"Error terminating {worker_id}")
# Give some time? Then destroy.
self.root.destroy()
else:
self.logger.info("Shutdown cancelled by user.")
return # Don't close
else:
self.logger.info("No running processes. Closing.")
self.root.destroy()
# --- End of MainWindow Class ---
# Standard boilerplate for testing this module directly (optional)
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
if not PROCESS_WORKER_ENABLED: # Redefine if import failed
class ProcessWorker:
def __init__(self, *a, **kw): pass
def run(self): pass;
def terminate(self): pass
class ToolInfo: display_name="Dummy"
tool_info=ToolInfo()
test_root = tk.Tk()
test_root.withdraw()
try:
app = MainWindow(test_root)
except Exception as e:
logging.exception("Failed to initialize MainWindow for testing.")
test_root.destroy()
sys.exit(1)
test_root.mainloop()