765 lines
41 KiB
Python
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() |