From 0c7ed33d0dfa7b4d02bb5fb5f597d060de8ae91c Mon Sep 17 00:00:00 2001 From: Miguel Date: Wed, 4 Jun 2025 21:38:40 +0200 Subject: [PATCH] =?UTF-8?q?Implementaci=C3=B3n=20de=20un=20sistema=20de=20?= =?UTF-8?q?logging=20para=20mejorar=20la=20gesti=C3=B3n=20de=20advertencia?= =?UTF-8?q?s=20y=20errores=20en=20la=20aplicaci=C3=B3n.=20Se=20establece?= =?UTF-8?q?=20un=20l=C3=ADmite=20para=20la=20cantidad=20de=20archivos=20de?= =?UTF-8?q?=20log=20generados=20y=20se=20optimiza=20la=20configuraci=C3=B3?= =?UTF-8?q?n=20de=20la=20ventana.=20Se=20ajusta=20el=20manejo=20de=20la=20?= =?UTF-8?q?evaluaci=C3=B3n=20y=20el=20autocompletado,=20mejorando=20la=20e?= =?UTF-8?q?xperiencia=20del=20usuario.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .doc/OLD_calc.py | 1262 +++++++++++++++++++++++++++++++++++++ calc.py | 15 + hybrid_calc_history.txt | 11 +- hybrid_calc_settings.json | 4 +- main_calc_app.py | 202 ++++-- main_evaluation.py | 8 +- 6 files changed, 1448 insertions(+), 54 deletions(-) create mode 100644 .doc/OLD_calc.py diff --git a/.doc/OLD_calc.py b/.doc/OLD_calc.py new file mode 100644 index 0000000..7ae8f73 --- /dev/null +++ b/.doc/OLD_calc.py @@ -0,0 +1,1262 @@ +""" +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() diff --git a/calc.py b/calc.py index 378898f..22df595 100644 --- a/calc.py +++ b/calc.py @@ -19,6 +19,7 @@ import platform def setup_logging(): """Configura el sistema de logging completo""" + MAX_LOG_FILES = 10 # Límite de archivos de log log_dir = Path("logs") log_dir.mkdir(exist_ok=True) @@ -26,6 +27,20 @@ def setup_logging(): timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") log_file = log_dir / f"mav_calc_{timestamp}.log" + # Eliminar logs antiguos si se supera el límite + try: + existing_logs = sorted( + [f for f in log_dir.glob("mav_calc_*.log") if f.is_file()], + key=os.path.getmtime + ) + if len(existing_logs) >= MAX_LOG_FILES: + logs_to_delete = existing_logs[:len(existing_logs) - MAX_LOG_FILES + 1] + for old_log in logs_to_delete: + old_log.unlink() + logging.info(f"Eliminado log antiguo: {old_log}") + except Exception as e: + logging.warning(f"No se pudieron eliminar logs antiguos: {e}") + # Configurar logging logging.basicConfig( level=logging.DEBUG, diff --git a/hybrid_calc_history.txt b/hybrid_calc_history.txt index adb71ff..db58340 100644 --- a/hybrid_calc_history.txt +++ b/hybrid_calc_history.txt @@ -1,10 +1,5 @@ +a = 10 + b +a=? -x=12 -a=x - -x=2 -b=x - -a -b \ No newline at end of file + \ No newline at end of file diff --git a/hybrid_calc_settings.json b/hybrid_calc_settings.json index 8bb3cfb..f679c19 100644 --- a/hybrid_calc_settings.json +++ b/hybrid_calc_settings.json @@ -1,6 +1,6 @@ { - "window_geometry": "1020x700+144+161", - "sash_pos_x": 355, + "window_geometry": "1020x700+467+199", + "sash_pos_x": 363, "symbolic_mode": true, "show_numeric_approximation": true, "keep_symbolic_fractions": true, diff --git a/main_calc_app.py b/main_calc_app.py index 0972f77..2cdafbd 100644 --- a/main_calc_app.py +++ b/main_calc_app.py @@ -6,6 +6,7 @@ import tkinter as tk from tkinter import scrolledtext, messagebox, Menu, filedialog import tkinter.font as tkFont import json +import logging # <--- AÑADIDO import os from pathlib import Path import threading @@ -35,10 +36,12 @@ except ImportError: except ImportError: HTML_VIEWER_TYPE = None +# Usar logging para estas advertencias iniciales +module_logger = logging.getLogger(__name__) if not MARKDOWN_AVAILABLE: - print("Advertencia: La librería 'markdown' no está instalada. La ayuda se mostrará en texto plano.") + module_logger.warning("La librería 'markdown' no está instalada. La ayuda se mostrará en texto plano.") if MARKDOWN_AVAILABLE and HTML_VIEWER_TYPE is None: - print("Advertencia: 'markdown' está disponible, pero no se encontró un visor HTML (tkinterweb/tkhtmlview). La ayuda se mostrará en texto plano.") + module_logger.warning("'markdown' está disponible, pero no se encontró un visor HTML (tkinterweb/tkhtmlview). La ayuda se mostrará en texto plano.") # ========== IMPORTS ADAPTADOS AL NUEVO SISTEMA ========== # Importar componentes del CAS híbrido con nuevo sistema de tipos @@ -58,6 +61,7 @@ class HybridCalculatorApp: def __init__(self, root: tk.Tk): self.root = root + self.logger = logging.getLogger(__name__) # <--- AÑADIDO: Logger para la instancia self.root.title("Calculadora MAV - CAS Híbrido") # Configuración y estado @@ -121,34 +125,35 @@ class HybridCalculatorApp: # Obtener helpers registrados dinámicamente self.HELPERS = get_registered_helper_functions() - # Añadir SympyHelper al final + # Añadir SympyHelper.Helper al final self.HELPERS.append(SympyHelper.Helper) - print(f"🆘 Helpers dinámicos cargados: {len(self.HELPERS)}") + # Usar logger en lugar de print, y sin emoji para la consola + self.logger.info(f"Helpers dinámicos cargados: {len(self.HELPERS)}") # Original: 🆘 except Exception as e: - print(f"⚠️ Error cargando helpers dinámicos: {e}") + # Usar logger en lugar de print, y sin emoji para la consola + self.logger.error(f"Error cargando helpers dinámicos: {e}", exc_info=True) # Original: ⚠️ # Fallback a helpers básicos self.HELPERS = [SympyHelper.Helper] def reload_types(self): """Recarga el sistema de tipos (útil para desarrollo)""" try: - print("🔄 Recargando sistema de tipos...") + self.logger.info("Recargando sistema de tipos...") # Original: 🔄 # Recargar engine self.engine.reload_types() # Recargar helpers self._setup_dynamic_helpers() - # Re-evaluar contenido actual self._evaluate_and_update() - print("✅ Sistema de tipos recargado") + self.logger.info("Sistema de tipos recargado.") # Original: ✅ except Exception as e: - print(f"❌ Error recargando tipos: {e}") + self.logger.error(f"Error recargando tipos: {e}", exc_info=True) # Original: ❌ messagebox.showerror("Error", f"Error recargando tipos:\n{e}") def show_types_info(self): @@ -186,15 +191,15 @@ CLASES DISPONIBLES: icon_path = script_dir / "icon.png" if not icon_path.is_file(): - print(f"Advertencia: Archivo de ícono no encontrado en '{icon_path}'.") + self.logger.warning(f"Archivo de ícono no encontrado en '{icon_path}'.") return self.app_icon = tk.PhotoImage(file=str(icon_path)) self.root.iconphoto(True, self.app_icon) except tk.TclError as e: - print(f"Advertencia: No se pudo cargar el ícono desde '{icon_path}'. Error de Tkinter: {e}") + self.logger.warning(f"No se pudo cargar el ícono desde '{icon_path}'. Error de Tkinter: {e}") except Exception as e: - print(f"Advertencia: Ocurrió un error inesperado al cargar el ícono desde '{icon_path}': {e}") + self.logger.warning(f"Ocurrió un error inesperado al cargar el ícono desde '{icon_path}': {e}", exc_info=True) def _load_settings(self) -> Dict[str, Any]: """Carga configuración de la aplicación""" @@ -221,7 +226,7 @@ CLASES DISPONIBLES: json.dump(self.settings, f, indent=4, ensure_ascii=False) except Exception as e: if self.debug: - print(f"Error guardando configuración: {e}") + self.logger.error(f"Error guardando configuración: {e}", exc_info=True) def update_symbolic_settings(self, symbolic_mode=None, show_numeric=None, keep_fractions=None, auto_simplify=None): @@ -444,8 +449,8 @@ CLASES DISPONIBLES: if event and event.char == '.' and self.input_text.focus_get() == self.input_text: self._handle_dot_autocomplete() - # Evaluación con debounce - self._debounce_job = self.root.after(300, self._evaluate_and_update) + # Evaluación con debounce y auto-dimensionado + self._debounce_job = self.root.after(300, self._process_input_and_adjust_layout) def _handle_dot_autocomplete(self): """Maneja el autocompletado cuando se escribe un punto - VERSIÓN DINÁMICA""" @@ -456,7 +461,7 @@ CLASES DISPONIBLES: char_idx_after_dot = int(char_num_str) if char_idx_after_dot == 0: - print("DEBUG: Autocomplete: Cursor at beginning of line after dot. No action.") + self.logger.debug("Autocomplete: Cursor at beginning of line after dot. No action.") return dot_char_index_in_line = char_idx_after_dot - 1 @@ -466,7 +471,7 @@ CLASES DISPONIBLES: # 1. Determinar si es un popup GLOBAL (usando contexto dinámico) if not stripped_text_before_dot: - print("DEBUG: Dot on empty line or after spaces. Offering global suggestions.") + self.logger.debug("Dot on empty line or after spaces. Offering global suggestions.") suggestions = [] # ========== USAR CONTEXTO DINÁMICO DEL REGISTRO ========== @@ -485,12 +490,12 @@ CLASES DISPONIBLES: if helper_text: hint = helper_text.split('\n')[0] except Exception as e_helper: - print(f"DEBUG: Error calling Helper for {name}: {e_helper}") + self.logger.debug(f"Error calling Helper for {name}: {e_helper}") pass suggestions.append((name, hint)) except Exception as e: - print(f"DEBUG: Error obteniendo contexto dinámico: {e}") + self.logger.debug(f"Error obteniendo contexto dinámico: {e}") # Fallback básico suggestions = [("sin", "Función seno"), ("cos", "Función coseno")] @@ -503,7 +508,7 @@ CLASES DISPONIBLES: if fname not in current_suggestion_names: suggestions.append((fname, fhint)) except Exception as e: - print(f"DEBUG: Error calling SympyHelper.PopupFunctionList() for global: {e}") + self.logger.debug(f"Error calling SympyHelper.PopupFunctionList() for global: {e}") if suggestions: suggestions.sort(key=lambda x: x[0]) @@ -522,48 +527,48 @@ CLASES DISPONIBLES: if not obj_expr_str_candidate or \ not re.match(r"^[a-zA-Z_0-9\(\)\[\]\.\"\'\+\-\*\/ ]*$", obj_expr_str_candidate) or \ obj_expr_str_candidate.endswith(("+", "-", "*", "/", "(", ",")): - print(f"DEBUG: Extracted expr '{obj_expr_str_candidate}' from '{stripped_text_before_dot}' not a valid object for dot autocomplete.") + self.logger.debug(f"Extracted expr '{obj_expr_str_candidate}' from '{stripped_text_before_dot}' not a valid object for dot autocomplete.") return obj_expr_str = obj_expr_str_candidate - print(f"DEBUG: Autocomplete for object. Extracted: '{obj_expr_str}' from: '{text_on_line_up_to_dot}'") + self.logger.debug(f"Autocomplete for object. Extracted: '{obj_expr_str}' from: '{text_on_line_up_to_dot}'") if not obj_expr_str: - print("DEBUG: Object expression is empty after extraction. No autocomplete.") + self.logger.debug("Object expression is empty after extraction. No autocomplete.") return # 3. Caso especial para el módulo sympy if obj_expr_str == "sympy": - print(f"DEBUG: Detected 'sympy.', using SympyHelper for suggestions.") + self.logger.debug(f"Detected 'sympy.', using SympyHelper for suggestions.") try: methods = SympyHelper.PopupFunctionList() if methods: self._show_autocomplete_popup(methods, is_global_popup=False) else: - print(f"DEBUG: SympyHelper.PopupFunctionList returned no methods.") + self.logger.debug(f"SympyHelper.PopupFunctionList returned no methods.") except Exception as e: - print(f"DEBUG: Error calling SympyHelper.PopupFunctionList(): {e}") + self.logger.debug(f"Error calling SympyHelper.PopupFunctionList(): {e}") return # 4. Preprocesar con BracketParser if '[' in obj_expr_str: original_for_debug = obj_expr_str obj_expr_str = self.engine.parser._transform_brackets(obj_expr_str) - if obj_expr_str != original_for_debug: - print(f"DEBUG: Preprocessed by BracketParser: '{original_for_debug}' -> '{obj_expr_str}'") + if obj_expr_str != original_for_debug and self.debug: # Solo loguear si self.debug es True + self.logger.debug(f"Preprocessed by BracketParser: '{original_for_debug}' -> '{obj_expr_str}'") # 5. Evaluar la expresión del objeto (usando contexto dinámico) eval_context = self.engine._get_full_context() obj = None try: if not obj_expr_str.strip(): - print("DEBUG: Object expression became empty before eval. No action.") + self.logger.debug("Object expression became empty before eval. No action.") return - print(f"DEBUG: Attempting to eval: '{obj_expr_str}'") + self.logger.debug(f"Attempting to eval: '{obj_expr_str}'") obj = eval(obj_expr_str, eval_context) - print(f"DEBUG: Eval successful. Object: {type(obj)}, Value: {obj}") + self.logger.debug(f"Eval successful. Object: {type(obj)}, Value: {obj}") except Exception as e: - print(f"DEBUG: Error evaluating object expression '{obj_expr_str}': {e}") + self.logger.debug(f"Error evaluating object expression '{obj_expr_str}': {e}") return # 6. Mostrar popup de autocompletado para el objeto @@ -793,7 +798,7 @@ CLASES DISPONIBLES: except Exception as e: if self.debug: - print(f"DEBUG: Error en get_result_tag_dynamic: {e}") + self.logger.debug(f"Error en get_result_tag_dynamic: {e}") # Fallback a tags existentes para tipos no registrados if isinstance(result, sympy.Basic): @@ -813,7 +818,7 @@ CLASES DISPONIBLES: except Exception as e: if self.debug: - print(f"DEBUG: Error en get_class_display_name_dynamic: {e}") + self.logger.debug(f"Error en get_class_display_name_dynamic: {e}") # Fallback a lógica existente para tipos nativos if isinstance(obj, sympy.logic.boolalg.BooleanAtom): @@ -933,7 +938,7 @@ CLASES DISPONIBLES: self.input_text.delete("1.0", tk.END) self.input_text.insert("1.0", content) - self._evaluate_and_update() + self._process_input_and_adjust_layout() except Exception as e: messagebox.showerror("Error", f"No se pudo cargar el archivo:\n{e}") @@ -1203,9 +1208,9 @@ programación y análisis numérico. self.input_text.insert("1.0", content) # Hacer evaluación inicial para mostrar resultados del historial # Esto mantiene el comportamiento de contexto limpio pero muestra resultados - self.root.after_idle(self._evaluate_and_update) + self.root.after_idle(self._process_input_and_adjust_layout) except Exception as e: - print(f"Error cargando historial: {e}") + self.logger.error(f"Error cargando historial: {e}", exc_info=True) def save_history(self): """Guarda historial de entrada""" @@ -1217,7 +1222,7 @@ programación y análisis numérico. elif os.path.exists(self.HISTORY_FILE): os.remove(self.HISTORY_FILE) except Exception as e: - print(f"Error guardando historial: {e}") + self.logger.error(f"Error guardando historial: {e}", exc_info=True) def on_close(self): """Maneja cierre de la aplicación""" @@ -1336,7 +1341,7 @@ programación y análisis numérico. html_viewer.pack(padx=0, pady=0, fill=tk.BOTH, expand=True) except Exception as e: - print(f"Error al renderizar Markdown a HTML: {e}") + self.logger.error(f"Error al renderizar Markdown a HTML: {e}", exc_info=True) # Fallback to text if HTML fails self._show_text_help(help_win, readme_content) else: @@ -1419,7 +1424,7 @@ Para crear un archivo de ayuda personalizado, cree un archivo `readme.md` en el if ayuda: return ayuda except Exception as e: - print(f"DEBUG: Error en helper: {e}") + self.logger.debug(f"Error en helper: {e}") continue return None @@ -1460,6 +1465,123 @@ Para crear un archivo de ayuda personalizado, cree un archivo `readme.md` en el return f"{mode}{numeric_indicator}{fractions_indicator}" + 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 opcional (descomenta si necesitas depurar) + if self.debug: + self.logger.debug(f"--- Adjusting Input Pane ---") + self.logger.debug(f"Input content: '{input_content[:50]}...'") + self.logger.debug(f"Max pixel width of text: {max_pixel_width}") + self.logger.debug(f"Width needed by text (max_pixel_width + padding): {width_needed_by_text}") + + min_input_pane_width = 200 # Definido en create_widgets + min_output_pane_width = 200 # 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: + if self.debug: + self.logger.debug("Could not get sash_coord.") + return + except tk.TclError: + if self.debug: + self.logger.debug("TclError getting sash_coord.") + return + + if self.debug: + self.logger.debug(f"Current sash position (input pane width): {current_sash_pos}") + + if width_needed_by_text > current_sash_pos: + if self.debug: + self.logger.debug(f"Condition MET: Text needs more space ({width_needed_by_text} > {current_sash_pos})") + 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 + if self.debug: + self.logger.debug(f"Calculated final new input width: {final_new_input_width}") + + # 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: + if self.debug: + self.logger.debug(f"Condition MET for sash_place: New width {final_new_input_width} is significantly larger (diff >= {sash_adjustment_threshold}).") + try: + if self.paned_window.winfo_exists() and total_width >= (min_input_pane_width + min_output_pane_width): + self.paned_window.sash_place(0, final_new_input_width, 0) # Añadido el argumento y=0 + if self.debug: + self.logger.debug(f"Sash placed at: {final_new_input_width}") + elif self.debug: + self.logger.debug("Paned window not ready or total width too small for sash_place.") + except tk.TclError as e_sash: + if self.debug: + self.logger.debug(f"TclError during sash_place: {e_sash}") + pass + elif self.debug: + self.logger.debug(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}).") + elif self.debug: + self.logger.debug(f"Condition NOT MET: Text does not need more space ({width_needed_by_text} <= {current_sash_pos})") + + if self.debug: + self.logger.debug(f"--- End Adjusting Input Pane ---") + + def _process_input_and_adjust_layout(self): + """Evalúa todas las líneas y luego ajusta el ancho del panel de entrada.""" + self._evaluate_and_update() + self._adjust_input_pane_width() + def main(): """Función principal""" diff --git a/main_evaluation.py b/main_evaluation.py index 93989c5..9e1613f 100644 --- a/main_evaluation.py +++ b/main_evaluation.py @@ -343,10 +343,10 @@ class HybridEvaluationEngine: try: numeric_eval = result.evalf() # Solo mostrar evaluación numérica si es diferente del resultado simbólico - if numeric_eval != result and not ( - hasattr(result, 'is_number') and result.is_number and - abs(float(numeric_eval) - float(result)) < 1e-15 - ): + if (str(numeric_eval) != str(result) and numeric_eval != result and + not (isinstance(result, (int, float)) or + (hasattr(result, 'is_number') and result.is_number and + hasattr(result, 'is_Integer') and result.is_Integer))): numeric_result = numeric_eval except: pass