""" Calculadora MAV - Archivo principal """ import tkinter as tk from tkinter import scrolledtext, messagebox, Menu, filedialog import ast import json import os import math import sympy import tkinter.font as tkFont # Importar las clases desde los módulos separados from base_types import BaseCalcType from hex_type import Hex from bin_type import Bin from ip4_type import IP4 from date_type import Date from dec_type import Dec from chr_type import Chr # Importar la nueva clase Chr from solve_type import solve from line_analyzer import LineResultCapture from sympy_integration import SymPyIntegration # Para la ayuda en HTML MARKDOWN_AVAILABLE = False HTML_VIEWER_TYPE = None try: import markdown MARKDOWN_AVAILABLE = True except ImportError: # markdown not available, MARKDOWN_AVAILABLE remains False pass # Se mostrará una advertencia más adelante si es necesario # Intentar importar visores HTML try: import tkinterweb HTML_VIEWER_TYPE = "tkinterweb" except ImportError: try: from tkhtmlview import HTMLScrolledText HTML_VIEWER_TYPE = "tkhtmlview" except ImportError: HTML_VIEWER_TYPE = None # Ambos visores fallaron if not MARKDOWN_AVAILABLE: print("Advertencia: La librería 'markdown' no está instalada. La ayuda podría mostrarse en texto plano o con formato limitado.") if MARKDOWN_AVAILABLE and HTML_VIEWER_TYPE is None: # Si markdown está pero no hay visor print("Advertencia: 'markdown' está disponible, pero no se encontró un visor HTML (tkinterweb/tkhtmlview). La ayuda se mostrará en texto plano.") class CalculatorApp: SETTINGS_FILE = "calc_settings.json" CUSTOM_CLASSES_WITH_HELPERS = [Hex, Bin, IP4, Date, Dec, solve, Chr] # Agregar Chr OPERATOR_HELP_MESSAGES = { "^": "Operador XOR a nivel de bits (ej: 5 ^ 3) o diferencia simétrica para conjuntos (ej: {1,2} ^ {2,3}). Requiere un segundo operando.", "+": "Operador de suma. Requiere un segundo operando.", "-": "Operador de resta. Requiere un segundo operando.", "*": "Operador de multiplicación. Requiere un segundo operando.", "/": "Operador de división. Requiere un segundo operando.", "**": "Operador de potencia. Requiere un segundo operando.", "//": "Operador de división entera. Requiere un segundo operando.", "%": "Operador de módulo. Requiere un segundo operando.", "&": "Operador AND a nivel de bits. Requiere un segundo operando.", "|": "Operador OR a nivel de bits. Requiere un segundo operando.", } def __init__(self, root): self.root = root self.root.title("Calculadora MAV") # Cargar y establecer el ícono de la aplicación try: self.app_icon = tk.PhotoImage(file="icon.png") self.root.iconphoto(True, self.app_icon) except tk.TclError: print("Advertencia: No se pudo cargar o establecer 'icon.png' como ícono de la aplicación.") self.settings = self._load_app_settings() self.root.geometry(self.settings.get("window_geometry", "900x600")) self.root.configure(bg="#2b2b2b") # Integración con SymPy self.sympy_integration = SymPyIntegration() # Funciones y constantes matemáticas math_functions = { "pi": math.pi, "e": math.e, "sin": math.sin, "cos": math.cos, "tan": math.tan, "asin": math.asin, "acos": math.acos, "atan": math.atan, "degrees": math.degrees, "radians": math.radians, "exp": math.exp, "log": math.log, "log10": math.log10, "log2": math.log2, "sqrt": math.sqrt, } self.calc_context_base = { "Hex": Hex, "Bin": Bin, "IP4": IP4, "Date": Date, "Dec": Dec, "Chr": Chr, "solve": solve, "hex": Hex, "bin": Bin, "ip4": IP4, "date": Date, "dec": Dec, "chr": Chr, # Alias en minúscula **math_functions, "__builtins__": { "print": self._custom_print, "abs": abs, "round": round, "len": len, "str": str, "int": int, "float": float, "list": list, "dict": dict, "tuple": tuple, "pow": pow, "sum": sum, "SyntaxError": SyntaxError, "min": min, "max": max, "range": range, "enumerate": enumerate, "zip": zip, "Hex": Hex, "Bin": Bin, "IP4": IP4, "Date": Date, "Dec": Dec, "Chr": Chr, "solve": solve, "hex": Hex, "bin": Bin, "ip4": IP4, "date": Date, "dec": Dec, "chr": Chr, # Alias en minúscula **math_functions, }, } self.output_buffer = [] self._debounce_job = None self._syncing_yview = False self._cached_input_font = None # Para cachear el objeto de fuente del panel de entrada self.create_widgets() self.load_persistent_input() def _load_app_settings(self): if os.path.exists(self.SETTINGS_FILE): try: with open(self.SETTINGS_FILE, "r", encoding="utf-8") as f: return json.load(f) except (IOError, json.JSONDecodeError): return {} return {} def _save_app_settings(self): self.settings["window_geometry"] = self.root.winfo_geometry() if hasattr(self, "paned_window"): try: sash_x_pos = self.paned_window.sash_coord(0)[0] self.settings["sash_pos_x"] = sash_x_pos except tk.TclError: pass try: with open(self.SETTINGS_FILE, "w", encoding="utf-8") as f: json.dump(self.settings, f, indent=4) except IOError: messagebox.showwarning("Error", "No se pudieron guardar los ajustes de la interfaz.") def create_widgets(self): main_frame = tk.Frame(self.root, bg="#2b2b2b", bd=0) main_frame.pack(fill=tk.BOTH, expand=True, padx=0, pady=0) self.paned_window = tk.PanedWindow( main_frame, orient=tk.HORIZONTAL, bg="#2b2b2b", sashrelief=tk.FLAT, sashwidth=4, bd=0, showhandle=False, opaqueresize=True, ) self.paned_window.pack(fill=tk.BOTH, expand=True, padx=0, pady=0) initial_input_width = self.settings.get("sash_pos_x", 400) self.input_text = scrolledtext.ScrolledText( self.paned_window, font=("Consolas", 11), bg="#1e1e1e", fg="#d4d4d4", insertbackground="#ffffff", selectbackground="#264f78", undo=True, wrap=tk.NONE, borderwidth=0, highlightthickness=0, relief=tk.FLAT, ) self.paned_window.add(self.input_text, width=initial_input_width, stretch="always", minsize=150) self.output_text = scrolledtext.ScrolledText( self.paned_window, font=("Consolas", 11), bg="#0f0f0f", fg="#00ff00", state="disabled", wrap=tk.NONE, borderwidth=0, highlightthickness=0, relief=tk.FLAT, ) self.paned_window.add(self.output_text, stretch="always", minsize=150) self.input_text.bind("", self.on_key_release) self.input_text.bind("", lambda e: self._show_context_menu(e, self.input_text, "input")) self.output_text.bind("", lambda e: self._show_context_menu(e, self.output_text, "output")) self.setup_scroll_sync() self.setup_output_tags() def _yscroll_input_command(self, *args): self.input_text.vbar.set(*args) if not self._syncing_yview: self._syncing_yview = True self.output_text.yview_moveto(args[0]) self._syncing_yview = False def _yscroll_output_command(self, *args): self.output_text.vbar.set(*args) if not self._syncing_yview: self._syncing_yview = True self.input_text.yview_moveto(args[0]) self._syncing_yview = False def _unified_mouse_wheel(self, event): if self._syncing_yview: return "break" if hasattr(event, "widget") and event.widget: event.widget.yview_scroll(int(-1 * (event.delta / 120)), "units") return "break" def setup_scroll_sync(self): self.input_text.config(yscrollcommand=self._yscroll_input_command) self.output_text.config(yscrollcommand=self._yscroll_output_command) self.input_text.bind("", self._unified_mouse_wheel) self.output_text.bind("", self._unified_mouse_wheel) def _show_context_menu(self, event, widget_ref, widget_type): context_menu = Menu( self.root, tearoff=0, bg="#3c3c3c", fg="white", activebackground="#007acc", activeforeground="white", relief=tk.FLAT, bd=1, ) if widget_type == "input": context_menu.add_command(label="Cortar", command=lambda: widget_ref.event_generate("<>")) context_menu.add_command(label="Copiar", command=lambda: widget_ref.event_generate("<>")) context_menu.add_command(label="Pegar", command=lambda: widget_ref.event_generate("<>")) context_menu.add_separator(background="#555555") context_menu.add_command(label="Borrar Todo", command=self._clear_input_text) context_menu.add_separator(background="#555555") context_menu.add_command(label="Guardar como...", command=self._save_input_as) context_menu.add_command(label="Cargar archivo...", command=self._load_input_from_file) elif widget_type == "output": context_menu.add_command(label="Copiar Todo", command=self._copy_all_output) context_menu.add_command(label="Borrar Salida", command=self._clear_output_text) context_menu.add_separator(background="#555555") context_menu.add_command(label="Ayuda", command=self._show_help_window) try: context_menu.tk_popup(event.x_root, event.y_root) finally: context_menu.grab_release() def _clear_output_text(self): self.output_text.config(state=tk.NORMAL) self.output_text.delete("1.0", tk.END) self.output_text.config(state=tk.DISABLED) def _copy_all_output(self): self.output_text.config(state=tk.NORMAL) content = self.output_text.get("1.0", tk.END).strip() self.output_text.config(state=tk.DISABLED) if content: self.root.clipboard_clear() self.root.clipboard_append(content) def _show_help_window(self): help_win = tk.Toplevel(self.root) help_win.title("Ayuda - Calculadora") help_win.geometry("750x600") # Usar un fondo oscuro consistente para la ventana de ayuda help_win.configure(bg="#1e1e1e") # Coincide con el fondo del HTMLScrolledText y CSS help_win.transient(self.root) readme_content = self._get_help_content() if MARKDOWN_AVAILABLE and HTML_VIEWER_TYPE: try: # CSS para un tema oscuro, consistente con la UI de la calculadora # Simplificado para no usar selectores 'body' o 'html' # y asumiendo que los estilos base se aplican al widget. dark_theme_css = """ """ html_fragment = markdown.markdown( readme_content, extensions=["fenced_code", "codehilite", "tables", "nl2br", "admonition"], extension_configs={"codehilite": {"noclasses": True, "pygments_style": "monokai"}} ) # Construir un documento HTML completo content_for_viewer = f""" Ayuda de Calculadora {dark_theme_css} {html_fragment} """ if HTML_VIEWER_TYPE == "tkinterweb": # tkinterweb.HtmlFrame no toma bg/fg directamente en constructor para el contenido html_viewer = tkinterweb.HtmlFrame(help_win, messages_enabled=False) html_viewer.load_html(content_for_viewer) # El fondo del Frame en sí se puede configurar si es necesario, # pero el body style debería controlarlo. # html_viewer.configure(background="#1e1e1e") elif HTML_VIEWER_TYPE == "tkhtmlview": html_viewer = HTMLScrolledText(help_win) html_viewer.configure(bg="#1e1e1e") html_viewer.set_html(content_for_viewer) html_viewer.pack(padx=0, pady=0, fill=tk.BOTH, expand=True) # El padding se maneja en el CSS except Exception as e: print(f"Error al renderizar Markdown a HTML: {e}") # Para depuración # Fallback to text if HTML fails self._show_text_help(help_win, readme_content) else: self._show_text_help(help_win, readme_content) close_button = tk.Button( help_win, text="Cerrar", command=help_win.destroy, bg="#3c3c3c", fg="white", relief=tk.FLAT, padx=10, ) close_button.pack(pady=(5, 10)) def _get_help_content(self): """Obtiene el contenido de ayuda desde readme.md o genera uno por defecto""" try: readme_path = "readme.md" if os.path.exists(readme_path): with open(readme_path, "r", encoding="utf-8") as f: return f.read() except IOError: pass # Contenido por defecto si no se encuentra readme.md return """# Calculadora MAV - Ayuda ## Nuevas Características: - **Ecuaciones algebraicas**: Usa comillas para definir ecuaciones: `"x + 10 = 15"` - **Resolver ecuaciones**: Usa `solve(x)` para resolver una variable - **Resolver múltiples variables**: `solve(x, y)` o `solve()` para todas - **Contexto dinámico**: Las variables definidas después de solve se usan automáticamente ## Ejemplo de uso algebraico: ``` "x + 10 = 15" "a*y - 5 = 20" solve(x) # x = 5 solve(y) # y = 25/a a = 3 y/25 # 1/3 ``` ## Clases disponibles: - `Hex()`: Números hexadecimales - `Bin()`: Números binarios - `IP4()`: Direcciones IPv4 - `Date()`: Fechas - `Dec()`: Decimales ## Funciones matemáticas: - Constantes: `pi`, `e` - Trigonométricas: `sin()`, `cos()`, `tan()`, `asin()`, `acos()`, `atan()` - Conversión Angular: `degrees()`, `radians()` - Exponenciales/Logarítmicas: `exp()`, `log()`, `log10()`, `log2()`, `sqrt()` - Variable `last` para el último resultado ## Menú Contextual (clic derecho): - En entrada: Cortar, Copiar, Pegar, Borrar Todo, Guardar como..., Cargar archivo... - En salida: Copiar Todo, Borrar Salida """ def _show_text_help(self, help_win, content): """Muestra la ayuda en texto plano cuando markdown no está disponible""" text_widget = scrolledtext.ScrolledText( help_win, font=("Consolas", 10), bg="#1e1e1e", fg="#d4d4d4", wrap=tk.WORD, borderwidth=0, highlightthickness=0 ) text_widget.insert("1.0", content) text_widget.config(state="disabled") text_widget.pack(padx=5, pady=5, fill=tk.BOTH, expand=True) def setup_output_tags(self): self.output_text.tag_configure("error", foreground="#ff6b6b", font=("Consolas", 11, "bold")) self.output_text.tag_configure("result", foreground="#abdbe3") self.output_text.tag_configure("hex", foreground="#f9a825") self.output_text.tag_configure("bin", foreground="#4fc3f7") self.output_text.tag_configure("ip", foreground="#fff176") self.output_text.tag_configure("date", foreground="#ff8a80") self.output_text.tag_configure("comment", foreground="#7f8c8d") self.output_text.tag_configure("print_output", foreground="#b0bec5") self.output_text.tag_configure("chr_type_tag", foreground="#80cbc4") # Teal-ish para Chr self.output_text.tag_configure("equation", foreground="#c792ea") self.output_text.tag_configure("type_hint", foreground="#6a6a6a") # Gris oscuro para el hint de tipo self.output_text.tag_configure("symbolic", foreground="#82aaff") def _custom_print(self, *args, **kwargs): sep = kwargs.get("sep", " ") output = sep.join(map(str, args)) self.output_buffer.append(("print", output)) def evaluate_all_lines(self): input_content = self.input_text.get("1.0", tk.END) if not input_content.strip(): self.set_output_text_with_colors([]) return lines = input_content.splitlines() output_data = [] # Limpiar ecuaciones de SymPy para re-evaluación completa self.sympy_integration.clear() # Crear un nuevo contexto para esta pasada de evaluación current_pass_context = self.calc_context_base.copy() current_pass_context["__builtins__"] = self.calc_context_base["__builtins__"].copy() last_val_for_current_line = None line_result_capture = LineResultCapture() # Store the context after each line for potential use by autocomplete # This is a simplification; a more robust system might be needed. self._line_contexts_for_autocomplete = {} for line_number, line_text_original in enumerate(lines, 1): self.output_buffer = [] current_pass_context["last"] = last_val_for_current_line current_line_outputs = [] comment_part = "" code_to_evaluate = line_text_original # Separar comentarios if "#" in code_to_evaluate: code_to_evaluate, comment_part = code_to_evaluate.split("#", 1) comment_part = "#" + comment_part code_to_evaluate_stripped = code_to_evaluate.strip() # Reemplazar "var = ?" por "solve('var')" if code_to_evaluate_stripped.endswith("=?"): parts = code_to_evaluate_stripped.rsplit("=?", 1) var_name_to_solve = parts[0].strip() if var_name_to_solve: # Escapar comillas dobles si estuvieran en var_name_to_solve (poco probable para nombres de var) safe_var_name = var_name_to_solve.replace('"', '\\"') code_to_evaluate_stripped = f'solve("{safe_var_name}")' # Verificar si es solo el nombre de una clase (para mostrar ayuda) showed_proactive_hint_for_class_name = False if code_to_evaluate_stripped: for cls in self.CUSTOM_CLASSES_WITH_HELPERS: if code_to_evaluate_stripped.lower() == cls.__name__.lower(): hint = cls.Helper(code_to_evaluate_stripped) if hint: current_line_outputs.append(("comment", hint)) if comment_part: current_line_outputs.append(("comment", " " + comment_part.strip())) showed_proactive_hint_for_class_name = True break if showed_proactive_hint_for_class_name: line_actual_object = last_val_for_current_line output_data.append(current_line_outputs) continue # Línea vacía if not code_to_evaluate_stripped: if comment_part: current_line_outputs.append(("comment", comment_part.strip())) else: current_line_outputs.append(("", "")) line_actual_object = last_val_for_current_line else: # Evaluar la línea if "__resultado_linea__" in current_pass_context: del current_pass_context["__resultado_linea__"] try: final_code_to_exec = line_result_capture.analyze_line(code_to_evaluate_stripped) processed_result = False # Inicializar al inicio # Verificar si es una ecuación antes de ejecutar if '=' in code_to_evaluate_stripped: # Intentar agregar como ecuación primero if self.sympy_integration.add_equation_from_string(code_to_evaluate_stripped, line_number): current_line_outputs.append(("equation", f"Ecuación: {code_to_evaluate_stripped}")) line_actual_object = None processed_result = True if not processed_result: exec(final_code_to_exec, current_pass_context) line_actual_object = current_pass_context.get("__resultado_linea__") if isinstance(line_actual_object, SyntaxError): raise line_actual_object # Actualizar el contexto de SymPy una vez después de exec y antes del procesamiento simbólico self.sympy_integration.update_context(current_pass_context) # Si el resultado de exec es una expresión SymPy, intentar evaluarla/simplificarla más if isinstance(line_actual_object, sympy.Expr): original_expr_from_exec = line_actual_object substitutions = self.sympy_integration._create_substitutions() if substitutions: evaluated_expr = original_expr_from_exec.subs(substitutions) else: evaluated_expr = original_expr_from_exec if evaluated_expr.is_number: line_actual_object = self.sympy_integration._format_number(evaluated_expr) else: simplified_expr = sympy.simplify(evaluated_expr) if simplified_expr.is_number: line_actual_object = self.sympy_integration._format_number(simplified_expr) else: line_actual_object = simplified_expr # Procesar el resultado if self.sympy_integration.is_equation_string(line_actual_object): if self.sympy_integration.add_equation_from_string(line_actual_object, line_number): current_line_outputs.append(("equation", f"Ecuación: {line_actual_object}")) else: current_line_outputs.append(("error", f"Error parseando ecuación: {line_actual_object}")) processed_result = True # Manejar instancias de solve elif isinstance(line_actual_object, solve): solve_obj = line_actual_object solve_obj.execute(self.sympy_integration, current_pass_context) # Mostrar output de print si hay for type_tag, printed_text in self.output_buffer: current_line_outputs.append((type_tag, printed_text)) if solve_obj.error: current_line_outputs.append(("error", solve_obj.error)) line_actual_object = last_val_for_current_line else: current_line_outputs.append(("symbolic", str(solve_obj))) if solve_obj.solutions is not None: if isinstance(solve_obj.solutions, dict): for var_name, sol_value in solve_obj.solutions.items(): if isinstance(sol_value, (int, float, complex, sympy.Expr, list, tuple)): current_pass_context[var_name] = sol_value elif solve_obj.var_names and len(solve_obj.var_names) == 1: var_name = solve_obj.var_names[0] if isinstance(solve_obj.solutions, (int, float, complex, sympy.Expr, list, tuple)): current_pass_context[var_name] = solve_obj.solutions line_actual_object = solve_obj.solutions processed_result = True # Verificar si podemos evaluar simbólicamente expresiones con variables no definidas elif not processed_result and isinstance(line_actual_object, NameError): sympy_result = self.sympy_integration.evaluate_expression( code_to_evaluate_stripped, current_pass_context ) if sympy_result is not None: current_line_outputs.append(("symbolic", sympy_result)) line_actual_object = sympy_result processed_result = True else: current_line_outputs.append(("error", f"NameError: {line_actual_object}")) line_actual_object = last_val_for_current_line processed_result = True # Resultado normal if not processed_result: # Mostrar output de print for type_tag, printed_text in self.output_buffer: current_line_outputs.append((type_tag, printed_text)) # Mostrar resultado si no es None if line_actual_object is not None: current_line_outputs.append(("result", line_actual_object)) except NameError as e: # Verificar si el error contiene un '=' y tratarlo como ecuación if '=' in code_to_evaluate_stripped: # Intentar agregar como ecuación if self.sympy_integration.add_equation_from_string(code_to_evaluate_stripped, line_number): current_line_outputs.append(("equation", f"Ecuación: {code_to_evaluate_stripped}")) line_actual_object = None # No hay resultado directo else: # Intentar evaluar simbólicamente sympy_result = self.sympy_integration.evaluate_expression( code_to_evaluate_stripped, current_pass_context ) if sympy_result is not None: current_line_outputs.append(("symbolic", sympy_result)) line_actual_object = sympy_result else: # Mostrar error normal si no se pudo agregar como ecuación current_line_outputs.append(("error", f"NameError: {e}")) line_actual_object = last_val_for_current_line else: # Intentar evaluar simbólicamente sympy_result = self.sympy_integration.evaluate_expression( code_to_evaluate_stripped, current_pass_context ) if sympy_result is not None: current_line_outputs.append(("symbolic", sympy_result)) line_actual_object = sympy_result else: # Mostrar error normal current_line_outputs.append(("error", f"NameError: {e}")) line_actual_object = last_val_for_current_line except Exception as e: # Mostrar output de print antes del error for type_tag, printed_text in self.output_buffer: current_line_outputs.append((type_tag, printed_text)) # Manejo de errores mejorado custom_error_message_applied = False if isinstance(e, SyntaxError): # Verificar si es un operador al final import re op_match = re.match(r"(.+?)\s*([+\-*/%^&|<>=!]{1,2})\s*$", code_to_evaluate_stripped) if op_match: operator = op_match.group(2) if operator == "*" and code_to_evaluate_stripped.endswith("**"): operator = "**" elif operator == "/" and code_to_evaluate_stripped.endswith("//"): operator = "//" if operator in self.OPERATOR_HELP_MESSAGES: help_text = self.OPERATOR_HELP_MESSAGES[operator] error_msg = f"Error de sintaxis cerca de '{operator}'. Ayuda: {help_text}" current_line_outputs.append(("error", error_msg)) custom_error_message_applied = True if not custom_error_message_applied: # Verificar si hay ayuda disponible hint_message_from_helper = None for cls in self.CUSTOM_CLASSES_WITH_HELPERS: hint = cls.Helper(code_to_evaluate_stripped) if hint: hint_message_from_helper = hint break if hint_message_from_helper: current_line_outputs.append(("comment", hint_message_from_helper)) else: error_type_name = type(e).__name__ error_detail = e.msg if hasattr(e, 'msg') else str(e) if hasattr(e, 'text') and e.text and not str(error_detail).startswith("Sintaxis inválida:"): error_detail += f" (en: '{e.text.strip()}')" standard_error_message = f"{error_type_name}: {error_detail}" current_line_outputs.append(("error", standard_error_message)) line_actual_object = last_val_for_current_line # Limpiar variable temporal if "__resultado_linea__" in current_pass_context: del current_pass_context["__resultado_linea__"] # Agregar comentario si existe if comment_part and code_to_evaluate_stripped: prefix_space = " " if current_line_outputs else "" current_line_outputs.append(("comment", prefix_space + comment_part.strip())) output_data.append(current_line_outputs) last_val_for_current_line = line_actual_object # Store a copy of the context as it is after this line's evaluation self._line_contexts_for_autocomplete[line_number] = current_pass_context.copy() self.set_output_text_with_colors(output_data) def _get_input_font(self): """Obtiene o crea y cachea el objeto tk.Font para el panel de entrada.""" if not self._cached_input_font: # Asume la fuente configurada en create_widgets: ("Consolas", 11) self._cached_input_font = tkFont.Font(family="Consolas", size=11) return self._cached_input_font def _adjust_input_pane_width(self): """Ajusta el ancho del panel de entrada según su contenido.""" if not hasattr(self, 'paned_window') or not self.paned_window.winfo_exists(): return # Esperar a que la ventana tenga un tamaño válido if self.paned_window.winfo_width() <= 1: return # Se reintentará en la siguiente llamada (ej. por KeyRelease) # Obtener contenido excluyendo el último newline automático del widget Text input_content = self.input_text.get("1.0", f"{tk.END}-1c") lines = input_content.splitlines() input_font = self._get_input_font() max_pixel_width = 0 if not input_content.strip(): # Si está vacío o solo espacios en blanco max_pixel_width = 5 # Ancho mínimo para el cursor o como placeholder else: for line in lines: measured_width = input_font.measure(line) if line.strip() else input_font.measure(" ") if measured_width > max_pixel_width: max_pixel_width = measured_width padding = 40 # Relleno para barra de desplazamiento, márgenes, etc. width_needed_by_text = max_pixel_width + padding # DEBUGGING PRINTS (descomentar si el problema persiste) # print(f"--- Adjusting Input Pane ---") # print(f"Input content: '{input_content[:50]}...'") # Muestra los primeros 50 chars del input # print(f"Max pixel width of text: {max_pixel_width}") # print(f"Width needed by text (max_pixel_width + padding): {width_needed_by_text}") # END DEBUGGING PRINTS min_input_pane_width = 150 # Definido en create_widgets min_output_pane_width = 150 # Definido en create_widgets total_width = self.paned_window.winfo_width() current_sash_pos = 0 try: sash_coords = self.paned_window.sash_coord(0) if sash_coords: current_sash_pos = sash_coords[0] else: # print("DEBUG: Could not get sash_coord.") # DEBUG return except tk.TclError: # print("DEBUG: TclError getting sash_coord.") # DEBUG return # print(f"Current sash position (input pane width): {current_sash_pos}") # DEBUG if width_needed_by_text > current_sash_pos: # print(f"Condition MET: Text needs more space ({width_needed_by_text} > {current_sash_pos})") # DEBUG new_input_width = width_needed_by_text # Punto de partida # Asegurar que el nuevo ancho no sea menor que el mínimo del panel de entrada new_input_width = max(new_input_width, min_input_pane_width) # Asegurar que el panel de salida conserve su espacio mínimo if total_width - new_input_width < min_output_pane_width: new_input_width = total_width - min_output_pane_width new_input_width = max(new_input_width, min_input_pane_width) # Re-verificar mínimo del input # Aplicar un ratio máximo para el panel de entrada max_input_ratio = 0.75 # Podría ser una constante de clase max_width_by_ratio = int(total_width * max_input_ratio) if new_input_width > max_width_by_ratio: if max_width_by_ratio >= min_input_pane_width and \ (total_width - max_width_by_ratio) >= min_output_pane_width: new_input_width = max_width_by_ratio final_new_input_width = max(0, int(new_input_width)) # No debe ser negativo # print(f"Calculated final new input width: {final_new_input_width}") # DEBUG # Mover el sash solo si el nuevo ancho es significativamente mayor que el actual (umbral ajustado) sash_adjustment_threshold = 3 # Píxeles if final_new_input_width > current_sash_pos and \ (final_new_input_width - current_sash_pos) >= sash_adjustment_threshold: # print(f"Condition MET for sash_place: New width {final_new_input_width} is significantly larger (diff >= {sash_adjustment_threshold}).") # DEBUG try: if self.paned_window.winfo_exists() and total_width >= (min_input_pane_width + min_output_pane_width): # type: ignore self.paned_window.sash_place(0, final_new_input_width, 0) # Añadido el argumento y=0 # print(f"Sash placed at: {final_new_input_width}") # DEBUG # else: # print("DEBUG: Paned window not ready or total width too small for sash_place.") #DEBUG except tk.TclError as e_sash: # print(f"DEBUG: TclError during sash_place: {e_sash}") # DEBUG pass # else: # print(f"Condition NOT MET for sash_place: final_new_input_width ({final_new_input_width}) vs current_sash_pos ({current_sash_pos}) or threshold ({sash_adjustment_threshold}).") # DEBUG # else: # print(f"Condition NOT MET: Text does not need more space ({width_needed_by_text} <= {current_sash_pos})") # DEBUG # print(f"--- End Adjusting Input Pane ---") _autocomplete_popup = None _autocomplete_listbox = None def _build_context_for_autocomplete(self, current_line_num_1_based): """ Builds an evaluation context based on lines executed *before* the current line. Uses a temporary SymPyIntegration instance to avoid side effects on the main one. """ lines = self.input_text.get("1.0", tk.END).splitlines() # Start with the base context eval_context = self.calc_context_base.copy() eval_context["__builtins__"] = self.calc_context_base["__builtins__"].copy() # Use a temporary SymPyIntegration for this context building temp_sympy_integration = SymPyIntegration() temp_sympy_integration.update_context(eval_context) # Prime with base symbols/values last_val_for_context_build = None line_result_capture = LineResultCapture() for i in range(current_line_num_1_based - 1): # Iterate lines *before* current if i >= len(lines): break # Should not happen if current_line_num is valid line_text = lines[i] eval_context["last"] = last_val_for_context_build code_to_run = line_text if "#" in code_to_run: code_to_run, _ = code_to_run.split("#", 1) code_to_run_stripped = code_to_run.strip() if not code_to_run_stripped: # Carry over 'last' or set to None if line is empty. # For simplicity, let's assume an empty line doesn't change 'last' from previous. continue try: final_code_to_exec = line_result_capture.analyze_line(code_to_run_stripped) exec(final_code_to_exec, eval_context) line_res_obj = eval_context.get("__resultado_linea__") if isinstance(line_res_obj, solve): line_res_obj.execute(temp_sympy_integration, eval_context) if line_res_obj.solutions is not None: if isinstance(line_res_obj.solutions, dict): for var_name, sol_val in line_res_obj.solutions.items(): eval_context[var_name] = sol_val elif line_res_obj.var_names and len(line_res_obj.var_names) == 1: eval_context[line_res_obj.var_names[0]] = line_res_obj.solutions last_val_for_context_build = line_res_obj.solutions elif temp_sympy_integration.is_equation_string(line_res_obj): temp_sympy_integration.add_equation_from_string(line_res_obj) last_val_for_context_build = line_res_obj else: last_val_for_context_build = line_res_obj temp_sympy_integration.update_context(eval_context) if "__resultado_linea__" in eval_context: del eval_context["__resultado_linea__"] except Exception: pass # Ignore errors in preceding lines for autocomplete context eval_context["last"] = last_val_for_context_build # Set 'last' for the current line's expression return eval_context def _handle_dot_autocomplete(self): self._close_autocomplete_popup() # Close any existing one first cursor_index_str = self.input_text.index(tk.INSERT) line_num_str, char_num_str = cursor_index_str.split('.') current_line_num = int(line_num_str) # 1-based char_idx_of_dot = int(char_num_str) # 0-based index of char after dot (where cursor is) # Get the text on the current line BEFORE the dot # The dot is at char_idx_of_dot - 1 obj_expr_str = self.input_text.get(f"{current_line_num}.0", f"{current_line_num}.{char_idx_of_dot -1}") if not obj_expr_str.strip(): return # Build context from lines *before* the current one eval_context = self._build_context_for_autocomplete(current_line_num) obj = None try: obj = eval(obj_expr_str, eval_context) except Exception: return # Can't evaluate object expression if obj is not None: methods = [] # Prioritize known, useful methods for custom types if isinstance(obj, IP4): methods.extend(["Mask", "Prefix", "Nodes", "NetworkAddress", "BroadcastAddress", "toHex"]) elif isinstance(obj, Date): methods.extend([]) # No specific simple methods other than operators yet # Add other callable attributes, excluding most dunder/private # Define methods from BaseCalcType that should be excluded from suggestions # if not overridden by the subclass in a meaningful public way. base_calc_type_public_methods_to_exclude = {"Helper", "original_str_for_repr"} for attr_name in dir(obj): if attr_name.startswith('_') and not attr_name.startswith('__'): # Exclude single underscore continue if attr_name.startswith('__'): # Exclude all attributes starting with double underscore continue if isinstance(obj, BaseCalcType) and attr_name in base_calc_type_public_methods_to_exclude: continue try: attr = getattr(obj, attr_name) if callable(attr): if attr_name not in methods: # Avoid duplicates methods.append(attr_name) except Exception: continue methods.sort() if methods: self._show_autocomplete_popup(methods, obj_expr_str, eval_context) def _show_autocomplete_popup(self, suggestions, obj_expr_str_for_paren, eval_context_for_paren): cursor_bbox = self.input_text.bbox(tk.INSERT) if not cursor_bbox: return x, y, _, height = cursor_bbox popup_x = self.input_text.winfo_rootx() + x popup_y = self.input_text.winfo_rooty() + y + height + 2 self._autocomplete_popup = tk.Toplevel(self.root) self._autocomplete_popup.wm_overrideredirect(True) self._autocomplete_popup.wm_geometry(f"+{popup_x}+{popup_y}") self._autocomplete_popup.attributes('-topmost', True) self.root.after(100, lambda: self._autocomplete_popup.attributes('-topmost', False) if self._autocomplete_popup else None) self._autocomplete_listbox = tk.Listbox( self._autocomplete_popup, bg="#3c3f41", fg="#bbbbbb", selectbackground="#007acc", selectforeground="white", borderwidth=1, relief="solid", exportselection=False, activestyle="none" ) for item in suggestions: self._autocomplete_listbox.insert(tk.END, item) if suggestions: self._autocomplete_listbox.select_set(0) self._autocomplete_listbox.pack(expand=True, fill=tk.BOTH) # Pass the expression and context needed for parenthesis logic self._autocomplete_listbox.bind("", lambda e, expr=obj_expr_str_for_paren, ctx=eval_context_for_paren: self._on_autocomplete_select(e, expr, ctx)) self._autocomplete_listbox.bind("", lambda e: self._close_autocomplete_popup()) self._autocomplete_listbox.bind("", lambda e, expr=obj_expr_str_for_paren, ctx=eval_context_for_paren: self._on_autocomplete_select(e, expr, ctx)) self._autocomplete_listbox.focus_set() self._autocomplete_listbox.bind("", lambda e: self._navigate_autocomplete(e, -1)) self._autocomplete_listbox.bind("", lambda e: self._navigate_autocomplete(e, 1)) self.input_text.bind("", self._on_input_focus_out_while_autocomplete, add=True) self.input_text.bind("", lambda e: self._close_autocomplete_popup(), add=True) # Click in input text self.root.bind("", self._on_root_click_while_autocomplete, add=True) # Click outside input text self.input_text.bind("", self._on_input_keypress_while_autocomplete, add=True) max_len = max(len(s) for s in suggestions) if suggestions else 10 width = max(15, min(max_len + 2, 50)) height = min(len(suggestions), 10) self._autocomplete_listbox.config(width=width, height=height) else: self._close_autocomplete_popup() def _navigate_autocomplete(self, event, direction): if not self._autocomplete_listbox: return "break" current_selection = self._autocomplete_listbox.curselection() if not current_selection: new_idx = 0 if direction == 1 else self._autocomplete_listbox.size() -1 else: idx = current_selection[0] new_idx = idx + direction if 0 <= new_idx < self._autocomplete_listbox.size(): if current_selection: self._autocomplete_listbox.select_clear(current_selection[0]) self._autocomplete_listbox.select_set(new_idx) self._autocomplete_listbox.activate(new_idx) self._autocomplete_listbox.see(new_idx) return "break" def _on_autocomplete_select(self, event, obj_expr_str, eval_context): if not self._autocomplete_listbox: return "break" selection = self._autocomplete_listbox.curselection() if not selection: self._close_autocomplete_popup(); return "break" selected_method = self._autocomplete_listbox.get(selection[0]) self.input_text.insert(tk.INSERT, selected_method) try: # Add parentheses if it's a method that usually takes them obj = eval(obj_expr_str, eval_context) # Re-eval to get the object method_attr = getattr(obj, selected_method, None) # Heuristic: if it's callable and not a class type itself (like Hex referring to Hex class) # and not a dunder that isn't usually called with () like __str__ if callable(method_attr) and not isinstance(method_attr, type) \ and not (selected_method.startswith("__") and selected_method.endswith("__")): self.input_text.insert(tk.INSERT, "()") self.input_text.mark_set(tk.INSERT, f"{tk.INSERT}-1c") # Cursor inside () except Exception: pass # If unsure, just insert name self._close_autocomplete_popup() self.input_text.focus_set() self.on_key_release() # Trigger re-evaluation return "break" def _close_autocomplete_popup(self, event=None): if self._autocomplete_popup: self.input_text.unbind("", self._on_input_focus_out_while_autocomplete_binding_id) self.input_text.unbind("") # Reconsider if this is too broad self.root.unbind("", self._on_root_click_while_autocomplete_binding_id) self.input_text.unbind("", self._on_input_keypress_while_autocomplete_binding_id) self._autocomplete_popup.destroy() self._autocomplete_popup = None self._autocomplete_listbox = None # Do not shift focus here if it was closed due to focus out or click elsewhere # Store binding IDs to unbind them correctly _on_input_focus_out_while_autocomplete_binding_id = None _on_root_click_while_autocomplete_binding_id = None _on_input_keypress_while_autocomplete_binding_id = None def _on_input_focus_out_while_autocomplete(self, event): # Close if focus is not going to the popup itself if self._autocomplete_popup and self.root.focus_get() != self._autocomplete_listbox: self._close_autocomplete_popup() def _on_root_click_while_autocomplete(self, event): if self._autocomplete_popup: # Check if the click was outside the input_text and outside the popup if event.widget != self.input_text and event.widget != self._autocomplete_listbox and \ (not isinstance(event.widget, tk.Listbox) or event.widget.master != self._autocomplete_popup): self._close_autocomplete_popup() def _on_input_keypress_while_autocomplete(self, event): if self._autocomplete_popup: if self.root.focus_get() == self._autocomplete_listbox: # Let listbox handle Up, Down, Return, Escape if event.keysym in ["Up", "Down", "Return", "Escape"]: return # For other keys (letters, numbers, backspace, space, etc.) when input has focus if event.keysym not in ["Shift_L", "Shift_R", "Control_L", "Control_R", "Alt_L", "Alt_R", "Caps_Lock", "Tab"]: if event.char != '.': # Typing a new dot will re-trigger _handle_dot_autocomplete self._close_autocomplete_popup() # Do not return "break", let the key press be processed by input_text # Allow event to propagate # In create_widgets, after self.input_text.bind("", ...): # Store binding IDs for later unbinding # self._on_input_focus_out_while_autocomplete_binding_id = self.input_text.bind("", self._on_input_focus_out_while_autocomplete, add=True) # self._on_root_click_while_autocomplete_binding_id = self.root.bind("", self._on_root_click_while_autocomplete, add=True) # self._on_input_keypress_while_autocomplete_binding_id = self.input_text.bind("", self._on_input_keypress_while_autocomplete, add=True) # The above bindings are now added dynamically when popup is shown. def set_output_text_with_colors(self, output_data_lines): self.output_text.config(state="normal") self.output_text.delete("1.0", tk.END) num_input_lines_str = self.input_text.index(f"{tk.END}-1c").split(".")[0] num_input_lines = int(num_input_lines_str) if num_input_lines_str else 0 for i, line_parts in enumerate(output_data_lines): is_output_line_effectively_empty = not line_parts or \ (len(line_parts) == 1 and line_parts[0][0] == "" and line_parts[0][1] == "") if not is_output_line_effectively_empty: first_part_on_line = True for item_index, (type_tag, content_obj) in enumerate(line_parts): if not first_part_on_line: prev_type_tag = line_parts[item_index - 1][0] if item_index > 0 else "" if type_tag == "comment" and prev_type_tag not in ["", "comment"]: self.output_text.insert(tk.END, " ") elif prev_type_tag != "": self.output_text.insert(tk.END, " ; ") content_str = "" type_hint_str = "" if content_obj is not None: is_pre_stringified_content = type_tag in ["equation", "error", "comment", "print_output"] or \ (type_tag == "symbolic" and isinstance(content_obj, str)) if is_pre_stringified_content: content_str = str(content_obj) else: content_str = str(content_obj) type_name = "" if isinstance(content_obj, BaseCalcType): type_name = content_obj.__class__.__name__ elif isinstance(content_obj, sympy.Basic): # sympy.Expr, sympy.Integer, etc. type_name = "sympy" # Ejemplo: y = 229/3 [sympy] elif isinstance(content_obj, list): if content_obj and all(isinstance(item, sympy.Basic) for item in content_obj): type_name = "list[sympy]" else: type_name = "list" elif isinstance(content_obj, tuple): type_name = "tuple" elif isinstance(content_obj, dict): type_name = "dict" elif not (isinstance(content_obj, BaseCalcType) or \ isinstance(content_obj, sympy.Basic) or \ isinstance(content_obj, (list, tuple, dict, str))): # Excluir str aquí type_name = type(content_obj).__name__ elif isinstance(content_obj, str) and type_tag == "result": # String resultado de una función type_name = "str" if type_name: type_hint_str = f" [{type_name}]" tag_to_apply = type_tag # Aplicar tags especiales según el tipo de objeto if type_tag == "result": if isinstance(content_obj, Hex): tag_to_apply = "hex" elif isinstance(content_obj, Bin): tag_to_apply = "bin" elif isinstance(content_obj, IP4): tag_to_apply = "ip" elif isinstance(content_obj, Date): tag_to_apply = "date" elif isinstance(content_obj, Chr): tag_to_apply = "chr_type_tag" if content_str or type_hint_str: # Solo insertar si hay algo que mostrar if content_str: # Puede ser que solo haya hint si content_str es "" (para None, pero se evita) self.output_text.insert(tk.END, content_str, tag_to_apply) if type_hint_str: self.output_text.insert(tk.END, type_hint_str, "type_hint") first_part_on_line = False # Agregar saltos de línea if i < len(output_data_lines) - 1: self.output_text.insert(tk.END, "\n") elif i < num_input_lines - 1: self.output_text.insert(tk.END, "\n") self.output_text.config(state="disabled") def save_persistent_input(self): try: content_to_save = self.input_text.get("1.0", tk.END).rstrip("\n") if content_to_save: with open("calc_input_history.txt", "w", encoding="utf-8") as f: f.write(content_to_save) elif os.path.exists("calc_input_history.txt"): os.remove("calc_input_history.txt") except Exception as e: messagebox.showwarning("Error", f"No se pudo guardar el historial: {e}") def load_persistent_input(self): try: if os.path.exists("calc_input_history.txt"): with open("calc_input_history.txt", "r", encoding="utf-8") as f: content = f.read() if content: self.input_text.insert("1.0", content) self.root.after_idle(self._process_input_and_adjust_layout) return # Ejemplo por defecto example_code = '''# Calculadora MAV - Ejemplos # Evaluación directa de Python 2 + 2 * 3 pi * 2 sin(pi/2) # Clases especiales Hex("FF") + 1 IP4("192.168.1.1") + 255 # Nueva clase Chr para ASCII Chr("A") Chr("Hello") Dec(66).toChr() # Devuelve Chr('B') Hex(0x43).toChr() # Devuelve Chr('C') # Ecuaciones algebraicas (usar comillas) "x + 10 = 15" "a*y - 5 = 20" # Resolver ecuaciones solve(x) solve(y) # Definir variables después a = 3 # Evaluar expresiones con variables y/25 # Expresiones simbólicas b + c * 2 ''' self.input_text.insert("1.0", example_code) self.root.after_idle(self._process_input_and_adjust_layout) except Exception as e: messagebox.showerror("Error", f"No se pudo cargar el historial o ejemplos: {e}") def on_close(self): self.save_persistent_input() self._save_app_settings() self.root.destroy() def _process_input_and_adjust_layout(self): """Evalúa todas las líneas y luego ajusta el ancho del panel de entrada.""" self.evaluate_all_lines() self._adjust_input_pane_width() # --- Métodos actualizados para usar _process_input_and_adjust_layout --- def on_key_release(self, event=None): if self._debounce_job: self.root.after_cancel(self._debounce_job) if event and event.char == '.' and self.input_text.focus_get() == self.input_text: self._handle_dot_autocomplete() self._debounce_job = self.root.after(250, self._process_input_and_adjust_layout) def _clear_input_text(self): self.input_text.delete("1.0", tk.END) self.sympy_integration.clear() self._process_input_and_adjust_layout() def _save_input_as(self): # Este método no cambia su lógica interna de guardado filepath = filedialog.asksaveasfilename( defaultextension=".txt", filetypes=[("Text Files", "*.txt"), ("Python Files", "*.py"), ("All Files", "*.*")], title="Guardar entrada como..." ) if not filepath: return try: with open(filepath, "w", encoding="utf-8") as f: f.write(self.input_text.get("1.0", tk.END)) except IOError as e: messagebox.showerror("Error al guardar", f"No se pudo guardar el archivo:\n{e}") def _load_input_from_file(self): filepath = filedialog.askopenfilename( filetypes=[("Text Files", "*.txt"), ("Python Files", "*.py"), ("All Files", "*.*")], title="Cargar entrada desde archivo..." ) if not filepath: return try: with open(filepath, "r", encoding="utf-8") as f: content = f.read() self.input_text.delete("1.0", tk.END) self.input_text.insert("1.0", content) self._process_input_and_adjust_layout() # Evaluar y ajustar layout except IOError as e: messagebox.showerror("Error al cargar", f"No se pudo cargar el archivo:\n{e}") except Exception as e: messagebox.showerror("Error al procesar", f"Error al procesar el archivo:\n{e}") def main(): root = tk.Tk() app = CalculatorApp(root) root.protocol("WM_DELETE_WINDOW", app.on_close) try: root.iconname("Calculadora MAV") except tk.TclError: pass root.mainloop() if __name__ == "__main__": main()