2093 lines
86 KiB
Python
2093 lines
86 KiB
Python
"""
|
|
Calculadora MAV CAS Híbrida - Aplicación principal
|
|
VERSIÓN ADAPTADA AL NUEVO SISTEMA DE TIPOS
|
|
"""
|
|
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
|
|
from typing import List, Dict, Any, Optional
|
|
import re
|
|
|
|
# ========== IMPORTS PARA SISTEMA DE AYUDA ==========
|
|
# 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
|
|
|
|
# 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
|
|
|
|
# Usar logging para estas advertencias iniciales
|
|
module_logger = logging.getLogger(__name__)
|
|
if not MARKDOWN_AVAILABLE:
|
|
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:
|
|
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
|
|
from main_evaluation_puro import PureAlgebraicEngine, EvaluationResult
|
|
from tl_popup import InteractiveResultManager, PlotResult
|
|
from type_registry import get_registered_helper_functions, get_registered_base_context
|
|
import sympy
|
|
from sympy_helper import SympyTools as SympyHelper
|
|
|
|
|
|
class HybridCalculatorApp:
|
|
"""Aplicación principal del CAS híbrido - ADAPTADA AL NUEVO SISTEMA"""
|
|
|
|
SETTINGS_FILE = "hybrid_calc_settings.json"
|
|
HISTORY_FILE = "hybrid_calc_history.txt"
|
|
HELP_FILE = "readme.md" # ========== NUEVO: Archivo de ayuda externo ==========
|
|
|
|
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
|
|
self.settings = self._load_settings()
|
|
self.root.geometry(self.settings.get("window_geometry", "1000x700"))
|
|
self.root.configure(bg="#2b2b2b")
|
|
|
|
# Debug desde configuración (definir antes del motor)
|
|
self.debug = self.settings.get("debug", False)
|
|
|
|
# Motor de evaluación - SISTEMA ALGEBRAICO PURO
|
|
self.engine = PureAlgebraicEngine()
|
|
|
|
# Configurar motor
|
|
if hasattr(self.engine, 'logger'):
|
|
self.engine.logger.setLevel(logging.DEBUG if self.debug else logging.INFO)
|
|
|
|
# ========== SISTEMA DE AUTOCOMPLETADO MEJORADO ==========
|
|
self._autocomplete_popup = None
|
|
self._autocomplete_listbox = None
|
|
self._current_suggestions = []
|
|
self._autocomplete_active = False
|
|
self._autocomplete_trigger_pos = None
|
|
self._autocomplete_filter_text = ""
|
|
self._popup_disabled_until_next_dot = False
|
|
self._last_navigation_time = 0 # Para evitar filtrado tras navegación
|
|
|
|
# Variables para autocompletado de variables
|
|
self._variable_popup_job = None
|
|
self._variable_popup_active = False
|
|
self._last_input_change = 0
|
|
|
|
# Configurar ícono
|
|
self._setup_icon()
|
|
|
|
# ========== COMPONENTES PRINCIPALES CON NUEVO SISTEMA ==========
|
|
self.interactive_manager = None # Se inicializa después de crear widgets
|
|
|
|
# ========== HELPERS DINÁMICOS DEL REGISTRO ==========
|
|
self._setup_dynamic_helpers()
|
|
|
|
# Estado de la aplicación
|
|
self._debounce_job = None
|
|
self._syncing_yview = False
|
|
self._cached_input_font = None
|
|
self.output_buffer = []
|
|
|
|
# ========== BARRA DE ESTADO ==========
|
|
self.status_frame = tk.Frame(self.root, bg="#2b2b2b", height=25)
|
|
self.status_frame.pack(side=tk.BOTTOM, fill=tk.X)
|
|
self.status_frame.pack_propagate(False)
|
|
|
|
self.status_label = tk.Label(
|
|
self.status_frame,
|
|
text="🔢 Calculadora MAV - Sistema Algebraico Puro",
|
|
bg="#2b2b2b",
|
|
fg="#80c7f7",
|
|
font=("Consolas", 9),
|
|
anchor=tk.W
|
|
)
|
|
self.status_label.pack(side=tk.LEFT, padx=10, pady=2)
|
|
|
|
# ========== PANEL PRINCIPAL ==========
|
|
self.create_widgets()
|
|
self.setup_interactive_manager()
|
|
self.load_history()
|
|
|
|
# Configurar eventos de cierre
|
|
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
|
|
|
|
# ========== BINDINGS DE TECLADO MEJORADOS ==========
|
|
self.input_text.bind("<KeyRelease>", self.on_key_release)
|
|
self.input_text.bind("<KeyPress>", self.on_key_press)
|
|
self.input_text.bind("<Button-1>", self._on_input_click)
|
|
self.input_text.bind("<FocusIn>", lambda e: self._close_autocomplete_popup())
|
|
|
|
# Bindings para navegación del autocompletado
|
|
self.input_text.bind("<Up>", self._handle_arrow_key)
|
|
self.input_text.bind("<Down>", self._handle_arrow_key)
|
|
self.input_text.bind("<Tab>", self._handle_tab_key)
|
|
self.input_text.bind("<Escape>", self._handle_escape_key)
|
|
|
|
def _setup_dynamic_helpers(self):
|
|
"""Configura helpers dinámicamente desde el registro de tipos"""
|
|
try:
|
|
# Obtener helpers registrados dinámicamente
|
|
self.HELPERS = get_registered_helper_functions()
|
|
|
|
# Añadir SympyHelper.Helper al final
|
|
self.HELPERS.append(SympyHelper.Helper)
|
|
|
|
# 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:
|
|
# 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:
|
|
self.logger.info("Recargando sistema de tipos...")
|
|
|
|
# Recargar helpers
|
|
self._setup_dynamic_helpers()
|
|
# Re-evaluar contenido actual
|
|
self._evaluate_and_update()
|
|
|
|
self.logger.info("Sistema de tipos recargado.")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error recargando tipos: {e}", exc_info=True)
|
|
messagebox.showerror("Error", f"Error recargando tipos:\n{e}")
|
|
|
|
def show_types_info(self):
|
|
"""Muestra información sobre tipos disponibles"""
|
|
try:
|
|
context_info = self.engine.get_context_info()
|
|
|
|
info_text = f"""INFORMACIÓN DEL SISTEMA ALGEBRAICO PURO
|
|
|
|
Ecuaciones en el sistema: {context_info.get('equations', 0)}
|
|
Variables definidas: {context_info.get('variables', 0)}
|
|
Variables activas: {', '.join(context_info.get('variable_names', []))}
|
|
|
|
CARACTERÍSTICAS:
|
|
• Sistema de ecuaciones puras con SymPy
|
|
• Todas las asignaciones son ecuaciones
|
|
• Resolución automática de sistemas
|
|
• Evaluación numérica inteligente
|
|
• Atajo x=? equivale a solve(x)
|
|
"""
|
|
|
|
# Mostrar en ventana
|
|
self._show_help_window("Información del Sistema", info_text)
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Error obteniendo información del sistema:\n{e}")
|
|
|
|
def _setup_icon(self):
|
|
"""Configura el ícono de la aplicación"""
|
|
try:
|
|
script_dir = Path(__file__).resolve().parent
|
|
icon_path = script_dir / "icon.png"
|
|
|
|
if not icon_path.is_file():
|
|
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:
|
|
self.logger.warning(f"No se pudo cargar el ícono desde '{icon_path}'. Error de Tkinter: {e}")
|
|
except Exception as 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"""
|
|
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_settings(self):
|
|
"""Guarda configuraciones en archivo JSON"""
|
|
try:
|
|
# Obtener geometría actual
|
|
self.settings["window_geometry"] = self.root.geometry()
|
|
|
|
# Guardar posición del panel divisor si existe
|
|
if hasattr(self, 'paned_window'):
|
|
sash_pos = self.paned_window.sash_coord(0)[0]
|
|
self.settings["sash_pos_x"] = sash_pos
|
|
|
|
with open(self.SETTINGS_FILE, "w", encoding="utf-8") as f:
|
|
json.dump(self.settings, f, indent=4, ensure_ascii=False)
|
|
except Exception as e:
|
|
if self.debug:
|
|
self.logger.error(f"Error guardando configuración: {e}", exc_info=True)
|
|
|
|
|
|
|
|
def create_widgets(self):
|
|
"""Crea la interfaz gráfica"""
|
|
# Frame principal
|
|
main_frame = tk.Frame(self.root, bg="#2b2b2b", bd=0)
|
|
main_frame.pack(fill=tk.BOTH, expand=True, padx=0, pady=0)
|
|
|
|
# Panel dividido
|
|
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)
|
|
|
|
# Panel de entrada
|
|
initial_input_width = self.settings.get("sash_pos_x", 450)
|
|
|
|
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=200
|
|
)
|
|
|
|
# Panel de salida
|
|
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=200
|
|
)
|
|
|
|
# Configurar eventos
|
|
self.input_text.bind("<Button-3>", lambda e: self._show_context_menu(e, "input"))
|
|
self.output_text.bind("<Button-3>", lambda e: self._show_context_menu(e, "output"))
|
|
|
|
# Configurar scroll sincronizado
|
|
self.setup_scroll_sync()
|
|
|
|
# Configurar tags de salida
|
|
self.setup_output_tags()
|
|
|
|
# Crear menú
|
|
self.create_menu()
|
|
|
|
def setup_interactive_manager(self):
|
|
"""Configura el gestor de resultados interactivos"""
|
|
self.interactive_manager = InteractiveResultManager(self.root)
|
|
# Configurar callback para actualizar el panel de entrada cuando se edite una expresión
|
|
self.interactive_manager.set_update_callback(self._update_input_expression)
|
|
|
|
def create_menu(self):
|
|
"""Crea el menú de la aplicación"""
|
|
menubar = Menu(self.root)
|
|
self.root.config(menu=menubar)
|
|
|
|
# Menú Archivo
|
|
file_menu = Menu(menubar, tearoff=0)
|
|
menubar.add_cascade(label="Archivo", menu=file_menu)
|
|
file_menu.add_command(label="Nuevo", command=self.new_session)
|
|
file_menu.add_separator()
|
|
file_menu.add_command(label="Cargar...", command=self.load_file)
|
|
file_menu.add_command(label="Guardar como...", command=self.save_file)
|
|
file_menu.add_separator()
|
|
file_menu.add_command(label="Salir", command=self.on_close)
|
|
|
|
# Menú Editar
|
|
edit_menu = Menu(menubar, tearoff=0)
|
|
menubar.add_cascade(label="Editar", menu=edit_menu)
|
|
edit_menu.add_command(label="Limpiar entrada", command=self.clear_input)
|
|
edit_menu.add_command(label="Limpiar salida", command=self.clear_output)
|
|
edit_menu.add_separator()
|
|
edit_menu.add_command(label="Limpiar historial", command=self.clear_history)
|
|
|
|
# Menú Herramientas (simplificado)
|
|
tools_menu = Menu(menubar, tearoff=0, bg="#3c3c3c", fg="white")
|
|
menubar.add_cascade(label="Herramientas", menu=tools_menu)
|
|
tools_menu.add_command(label="Recargar Tipos Personalizados", command=self.reload_types)
|
|
|
|
# ========== MENÚ TIPOS (NUEVO) ==========
|
|
types_menu = Menu(menubar, tearoff=0, bg="#3c3c3c", fg="white")
|
|
menubar.add_cascade(label="Tipos", menu=types_menu)
|
|
types_menu.add_command(label="Información de tipos", command=self.show_types_info)
|
|
types_menu.add_separator()
|
|
types_menu.add_command(label="Sintaxis de tipos", command=self.show_types_syntax)
|
|
|
|
# Menú Ayuda (actualizado)
|
|
help_menu = Menu(menubar, tearoff=0)
|
|
menubar.add_cascade(label="Ayuda", menu=help_menu)
|
|
help_menu.add_command(label="Guía rápida", command=self.show_quick_guide)
|
|
help_menu.add_command(label="Sintaxis", command=self.show_syntax_help)
|
|
help_menu.add_command(label="Funciones SymPy", command=self.show_sympy_functions)
|
|
help_menu.add_separator()
|
|
help_menu.add_command(label="Acerca de", command=self.show_about)
|
|
|
|
def setup_scroll_sync(self):
|
|
"""Configura scroll sincronizado entre paneles"""
|
|
def _yscroll_input_command(*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(*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(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"
|
|
|
|
self.input_text.config(yscrollcommand=_yscroll_input_command)
|
|
self.output_text.config(yscrollcommand=_yscroll_output_command)
|
|
self.input_text.bind("<MouseWheel>", _unified_mouse_wheel)
|
|
self.output_text.bind("<MouseWheel>", _unified_mouse_wheel)
|
|
|
|
def setup_output_tags(self):
|
|
"""Configura tags de formato para el panel de salida"""
|
|
default_font = self._get_input_font()
|
|
|
|
# Crear una fuente específica para errores (bold)
|
|
error_font = tkFont.Font(family=default_font.cget("family"), size=default_font.cget("size"), weight="bold")
|
|
|
|
# Tag base
|
|
self.output_text.tag_configure("base", font=default_font, foreground="#d4d4d4")
|
|
|
|
# Tags específicos
|
|
# Sympy y tipos base
|
|
self.output_text.tag_configure("symbolic", foreground="#9cdcfe") # Azul claro (SymPy)
|
|
self.output_text.tag_configure("numeric", foreground="#b5cea8") # Verde (Números)
|
|
self.output_text.tag_configure("boolean", foreground="#569cd6") # Azul (Booleanos)
|
|
self.output_text.tag_configure("string", foreground="#ce9178") # Naranja (Strings)
|
|
|
|
# Tipos registrados dinámicamente (usar un color base)
|
|
self.output_text.tag_configure("custom_type", foreground="#4ec9b0") # Turquesa (Tipos Custom)
|
|
|
|
# Estado de la aplicación
|
|
self.output_text.tag_configure("error", foreground="#f44747", font=error_font) # Rojo
|
|
self.output_text.tag_configure("comment", foreground="#6a9955") # Verde Oliva (Comentarios)
|
|
self.output_text.tag_configure("assignment", foreground="#dcdcaa") # Amarillo (Asignaciones)
|
|
self.output_text.tag_configure("equation", foreground="#c586c0") # Púrpura (Ecuaciones)
|
|
self.output_text.tag_configure("plot", foreground="#569cd6", underline=True) # Azul con subrayado (Plots)
|
|
|
|
# Para el nuevo indicador de tipo algebraico
|
|
self.output_text.tag_configure("type_indicator", foreground="#808080") # Gris oscuro
|
|
|
|
# Configurar tags para tipos específicos si es necesario (ejemplo)
|
|
# self.output_text.tag_configure("IP4", foreground="#4ec9b0")
|
|
# self.output_text.tag_configure("IntBase", foreground="#4ec9b0")
|
|
|
|
def on_key_press(self, event=None):
|
|
"""Maneja eventos de presión de tecla (antes de que se inserte el carácter)"""
|
|
# Si el popup está activo, manejar navegación y selección
|
|
if (self._autocomplete_active or self._variable_popup_active) and event:
|
|
if event.keysym in ['Up', 'Down']:
|
|
return self._handle_arrow_key(event)
|
|
elif event.keysym == 'Tab':
|
|
return self._handle_tab_key(event)
|
|
elif event.keysym == 'Escape':
|
|
return self._handle_escape_key(event)
|
|
|
|
# Detectar backspace para cerrar popup de funciones si se borra el punto
|
|
if event and event.keysym == 'BackSpace' and self._autocomplete_active:
|
|
self._check_dot_removal()
|
|
|
|
def _check_dot_removal(self):
|
|
"""Verifica si se va a borrar el punto que activó el autocompletado"""
|
|
try:
|
|
# Obtener posición del cursor
|
|
cursor_pos = self.input_text.index(tk.INSERT)
|
|
|
|
# Obtener el carácter anterior al cursor
|
|
if cursor_pos != "1.0": # No estamos al inicio del texto
|
|
prev_char_pos = f"{cursor_pos}-1c"
|
|
prev_char = self.input_text.get(prev_char_pos, cursor_pos)
|
|
|
|
# Si el carácter anterior es un punto, cerrar el popup
|
|
if prev_char == '.':
|
|
# Programar cierre después del backspace
|
|
self.root.after(1, self._close_autocomplete_popup)
|
|
|
|
except tk.TclError:
|
|
# Error de posición, cerrar popup por seguridad
|
|
self._close_autocomplete_popup()
|
|
|
|
def on_key_release(self, event=None):
|
|
"""Maneja eventos de teclado después de insertar carácter"""
|
|
if self._debounce_job:
|
|
self.root.after_cancel(self._debounce_job)
|
|
|
|
# Cancelar job de autocompletado de variables si existe
|
|
if self._variable_popup_job:
|
|
self.root.after_cancel(self._variable_popup_job)
|
|
self._variable_popup_job = None
|
|
|
|
# Verificar si acabamos de navegar (evitar filtrado inmediato)
|
|
import time
|
|
just_navigated = (time.time() - self._last_navigation_time) < 0.1
|
|
|
|
# Manejar autocompletado con punto
|
|
if event and event.char == '.' and self.input_text.focus_get() == self.input_text:
|
|
# Cerrar popup de variables si está activo
|
|
if self._variable_popup_active:
|
|
self._close_autocomplete_popup()
|
|
|
|
if not self._popup_disabled_until_next_dot:
|
|
self._handle_dot_autocomplete()
|
|
else:
|
|
# Resetear flag cuando se escribe un nuevo punto
|
|
self._popup_disabled_until_next_dot = False
|
|
|
|
# Filtrar autocompletado si está activo (pero no si acabamos de navegar)
|
|
elif self._autocomplete_active and event and event.char.isprintable() and not just_navigated:
|
|
self._filter_autocomplete()
|
|
|
|
# Marcar tiempo del último cambio de input
|
|
if event and event.char.isprintable():
|
|
self._last_input_change = time.time()
|
|
|
|
# Evaluación con debounce y auto-dimensionado
|
|
self._debounce_job = self.root.after(300, self._process_input_and_adjust_layout)
|
|
|
|
# Programar autocompletado de variables (nuevo sistema)
|
|
self._schedule_variable_autocomplete_improved()
|
|
|
|
def _schedule_variable_autocomplete_improved(self):
|
|
"""Programa el autocompletado de variables mientras se escribe"""
|
|
# Solo si no hay popup de funciones activo
|
|
if self._autocomplete_active or self._popup_disabled_until_next_dot:
|
|
self.logger.debug("Variable autocomplete: Saltando - popup activo o deshabilitado")
|
|
return
|
|
|
|
# Verificar que estemos escribiendo (no solo navegando)
|
|
current_line = self.input_text.get("insert linestart", "insert lineend").strip()
|
|
if not current_line or current_line.endswith('.'):
|
|
self.logger.debug(f"Variable autocomplete: Saltando - línea vacía o termina en punto: '{current_line}'")
|
|
return
|
|
|
|
# Cancelar job anterior si existe
|
|
if self._variable_popup_job:
|
|
self.root.after_cancel(self._variable_popup_job)
|
|
|
|
self.logger.debug(f"Variable autocomplete: Programando para línea: '{current_line}'")
|
|
# Programar para 800ms después
|
|
self._variable_popup_job = self.root.after(800, self._show_variable_autocomplete_improved)
|
|
|
|
def _show_variable_autocomplete_improved(self):
|
|
"""Muestra autocompletado de variables disponibles (simplificado)"""
|
|
self.logger.debug("Variable autocomplete: Ejecutando show_variable_autocomplete_improved")
|
|
|
|
if self._autocomplete_active or self._variable_popup_active:
|
|
self.logger.debug("Variable autocomplete: Saltando - ya hay popup activo")
|
|
return # Ya hay un popup activo
|
|
|
|
# Verificar que aún estemos en una línea válida
|
|
current_line = self.input_text.get("insert linestart", "insert lineend").strip()
|
|
if not current_line or current_line.endswith('.'):
|
|
self.logger.debug(f"Variable autocomplete: Saltando - línea inválida: '{current_line}'")
|
|
self._variable_popup_job = None
|
|
return
|
|
|
|
# Obtener variables del contexto
|
|
try:
|
|
context = self.engine._get_full_context()
|
|
self.logger.debug(f"Variable autocomplete: Contexto completo tiene {len(context)} elementos")
|
|
|
|
# Mostrar tabla de símbolos específicamente
|
|
symbol_table = getattr(self.engine, 'symbol_table', {})
|
|
self.logger.debug(f"Variable autocomplete: Symbol table tiene {len(symbol_table)} elementos: {list(symbol_table.keys())}")
|
|
|
|
variables = []
|
|
|
|
# Filtrar variables (excluir funciones built-in y módulos)
|
|
for name, value in context.items():
|
|
# Debug detallado de cada elemento
|
|
is_underscore = name.startswith('_')
|
|
is_callable = callable(value)
|
|
has_module = hasattr(value, '__module__')
|
|
is_excluded = name in ['sympy', 'math', 'numpy', 'plt', 'builtins']
|
|
|
|
# Permitir variables de SymPy específicamente (ANTES del log)
|
|
is_sympy_symbol = hasattr(value, 'is_symbol') or 'sympy' in str(type(value)).lower()
|
|
|
|
self.logger.debug(f"Variable autocomplete: Analizando '{name}': underscore={is_underscore}, callable={is_callable}, module={has_module}, excluded={is_excluded}, sympy_symbol={is_sympy_symbol}, type={type(value)}")
|
|
|
|
if (not is_underscore and
|
|
not is_callable and
|
|
(not has_module or is_sympy_symbol) and # Permitir SymPy symbols
|
|
not is_excluded):
|
|
|
|
self.logger.debug(f"Variable autocomplete: ✅ Aceptando variable '{name}' = {value}")
|
|
|
|
# Crear descripción del valor (más corta)
|
|
value_str = str(value)
|
|
if len(value_str) > 20:
|
|
value_str = value_str[:17] + "..."
|
|
|
|
variables.append((name, value_str))
|
|
else:
|
|
self.logger.debug(f"Variable autocomplete: ❌ Rechazando '{name}' por filtros")
|
|
|
|
self.logger.debug(f"Variable autocomplete: Encontradas {len(variables)} variables totales")
|
|
|
|
if variables:
|
|
variables.sort(key=lambda x: x[0])
|
|
|
|
# Obtener texto actual para filtrado
|
|
words = current_line.split()
|
|
self.logger.debug(f"Variable autocomplete: Palabras en línea: {words}")
|
|
|
|
if words:
|
|
last_word = words[-1]
|
|
self.logger.debug(f"Variable autocomplete: Última palabra: '{last_word}'")
|
|
|
|
# Filtrar variables que empiecen con la palabra actual
|
|
# Y que la palabra actual no sea igual a una variable existente
|
|
filtered_vars = [
|
|
(name, value) for name, value in variables
|
|
if name.lower().startswith(last_word.lower()) and name != last_word
|
|
]
|
|
|
|
self.logger.debug(f"Variable autocomplete: Variables filtradas: {len(filtered_vars)}")
|
|
|
|
if filtered_vars:
|
|
# Posicionar en el cursor actual
|
|
self._autocomplete_trigger_pos = self.input_text.index(tk.INSERT)
|
|
self._autocomplete_filter_text = ""
|
|
|
|
# Mostrar popup de variables menos invasivo
|
|
self._show_variable_popup(filtered_vars)
|
|
|
|
self.logger.debug(f"Mostrando autocompletado de variables: {len(filtered_vars)} encontradas")
|
|
else:
|
|
self.logger.debug("Variable autocomplete: No hay variables filtradas que mostrar")
|
|
else:
|
|
self.logger.debug("Variable autocomplete: No hay palabras en la línea")
|
|
else:
|
|
self.logger.debug("Variable autocomplete: No hay variables en el contexto")
|
|
|
|
except Exception as e:
|
|
self.logger.debug(f"Error obteniendo variables para autocompletado: {e}")
|
|
|
|
# Limpiar job
|
|
self._variable_popup_job = None
|
|
|
|
def _show_variable_popup(self, variables):
|
|
"""Muestra popup de variables con estilo menos invasivo"""
|
|
cursor_bbox = self.input_text.bbox(tk.INSERT)
|
|
if not cursor_bbox:
|
|
return
|
|
|
|
# Marcar como popup de variables activo
|
|
self._variable_popup_active = True
|
|
self._autocomplete_active = False # No es el popup principal
|
|
|
|
x, y, _, height = cursor_bbox
|
|
popup_x = self.input_text.winfo_rootx() + x
|
|
popup_y = self.input_text.winfo_rooty() + y + height + 2
|
|
|
|
# Crear popup más discreto
|
|
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)
|
|
|
|
# Crear listbox con colores discretos pero visibles
|
|
self._autocomplete_listbox = tk.Listbox(
|
|
self._autocomplete_popup,
|
|
bg="#2d2d30", # Más oscuro
|
|
fg="#c9c9c9", # Texto más visible que antes
|
|
selectbackground="#4a4a4a", # Selección más visible
|
|
selectforeground="#ffffff",
|
|
borderwidth=1,
|
|
relief="solid",
|
|
exportselection=False,
|
|
activestyle="none",
|
|
font=("Consolas", 10) # Fuente legible
|
|
)
|
|
|
|
# Llenar con variables (formato más simple)
|
|
for name, value in variables:
|
|
self._autocomplete_listbox.insert(tk.END, f"{name} = {value}")
|
|
|
|
if variables:
|
|
self._autocomplete_listbox.select_set(0)
|
|
self._autocomplete_listbox.pack(expand=True, fill=tk.BOTH)
|
|
|
|
# Solo doble-click para seleccionar (más discreto)
|
|
self._autocomplete_listbox.bind("<Double-Button-1>",
|
|
lambda e: self._select_variable())
|
|
|
|
# Binding para cerrar si se hace click fuera
|
|
self.root.bind("<Button-1>", self._on_click_outside_variable, add=True)
|
|
|
|
# Calcular tamaño más compacto
|
|
max_len = 15
|
|
for name, value in variables:
|
|
item_text = f"{name} = {value}"
|
|
max_len = max(max_len, len(item_text))
|
|
|
|
width = min(max_len + 3, 40)
|
|
height = min(len(variables), 5)
|
|
|
|
self._autocomplete_listbox.config(width=width, height=height)
|
|
else:
|
|
self._close_autocomplete_popup()
|
|
|
|
def _handle_arrow_key(self, event):
|
|
"""Maneja las teclas de flecha cuando el popup está activo"""
|
|
if not self._autocomplete_active and not self._variable_popup_active:
|
|
return # Permitir comportamiento normal
|
|
|
|
direction = -1 if event.keysym == 'Up' else 1
|
|
self._navigate_autocomplete_improved(direction)
|
|
|
|
# Marcar tiempo de navegación para evitar filtrado inmediato
|
|
import time
|
|
self._last_navigation_time = time.time()
|
|
|
|
return "break" # Prevenir comportamiento normal
|
|
|
|
def _handle_tab_key(self, event):
|
|
"""Maneja la tecla TAB para seleccionar del popup"""
|
|
if self._autocomplete_active or self._variable_popup_active:
|
|
self._select_autocomplete()
|
|
return "break"
|
|
return # Permitir comportamiento normal si no hay popup
|
|
|
|
def _handle_escape_key(self, event):
|
|
"""Maneja la tecla ESC para cerrar popup"""
|
|
if self._autocomplete_active or self._variable_popup_active:
|
|
self._close_autocomplete_popup()
|
|
if self._autocomplete_active:
|
|
self._popup_disabled_until_next_dot = True
|
|
return "break"
|
|
return # Permitir comportamiento normal si no hay popup
|
|
|
|
def _on_input_click(self, event):
|
|
"""Maneja clicks en el campo de entrada"""
|
|
self._close_autocomplete_popup()
|
|
|
|
def _handle_dot_autocomplete(self):
|
|
"""Maneja el autocompletado cuando se escribe un punto - VERSIÓN MEJORADA"""
|
|
self._close_autocomplete_popup()
|
|
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)
|
|
char_idx_after_dot = int(char_num_str)
|
|
|
|
if char_idx_after_dot == 0:
|
|
self.logger.debug("Autocomplete: Cursor at beginning of line after dot. No action.")
|
|
return
|
|
|
|
# Guardar posición donde se activó el autocompletado
|
|
self._autocomplete_trigger_pos = f"{current_line_num}.{char_idx_after_dot}"
|
|
self._autocomplete_filter_text = ""
|
|
|
|
dot_char_index_in_line = char_idx_after_dot - 1
|
|
text_on_line_up_to_dot = self.input_text.get(f"{current_line_num}.0", f"{current_line_num}.{dot_char_index_in_line}")
|
|
|
|
stripped_text_before_dot = text_on_line_up_to_dot.strip()
|
|
|
|
# 1. Determinar si es un popup GLOBAL (usando contexto dinámico)
|
|
if not stripped_text_before_dot:
|
|
self.logger.debug("Dot on empty line or after spaces. Offering global suggestions.")
|
|
suggestions = []
|
|
|
|
# ========== USAR CONTEXTO DINÁMICO DEL REGISTRO ==========
|
|
try:
|
|
dynamic_context = get_registered_base_context()
|
|
|
|
for name, class_or_func in dynamic_context.items():
|
|
if name[0].isupper(): # Prioritizar nombres capitalizados
|
|
hint = f"Tipo o función: {name}"
|
|
if hasattr(class_or_func, '__doc__') and class_or_func.__doc__:
|
|
first_line_doc = class_or_func.__doc__.strip().split('\n')[0]
|
|
hint = f"{name} - {first_line_doc}"
|
|
elif hasattr(class_or_func, 'Helper'):
|
|
try:
|
|
helper_text = class_or_func.Helper(name)
|
|
if helper_text:
|
|
hint = helper_text.split('\n')[0]
|
|
except Exception as e_helper:
|
|
self.logger.debug(f"Error calling Helper for {name}: {e_helper}")
|
|
pass
|
|
suggestions.append((name, hint))
|
|
|
|
except Exception as e:
|
|
self.logger.debug(f"Error obteniendo contexto dinámico: {e}")
|
|
# Fallback básico
|
|
suggestions = [("sin", "Función seno"), ("cos", "Función coseno")]
|
|
|
|
# Añadir funciones de SympyHelper
|
|
try:
|
|
sympy_functions = SympyHelper.PopupFunctionList()
|
|
if sympy_functions:
|
|
current_suggestion_names = {s[0] for s in suggestions}
|
|
for fname, fhint in sympy_functions:
|
|
if fname not in current_suggestion_names:
|
|
suggestions.append((fname, fhint))
|
|
except Exception as e:
|
|
self.logger.debug(f"Error calling SympyHelper.PopupFunctionList() for global: {e}")
|
|
|
|
if suggestions:
|
|
suggestions.sort(key=lambda x: x[0])
|
|
self._show_autocomplete_popup(suggestions, is_global_popup=True)
|
|
return
|
|
|
|
# 2. Es un popup de OBJETO
|
|
obj_expr_str_candidate = ""
|
|
obj_expr_regex = r"([a-zA-Z_][a-zA-Z0-9_]*(?:\[[^\]]*\])?(?:(?:\s*\.\s*[a-zA-Z_][a-zA-Z0-9_]*)(?:\[[^\]]*\])?)*)$"
|
|
match = re.search(obj_expr_regex, stripped_text_before_dot)
|
|
|
|
if match:
|
|
obj_expr_str_candidate = match.group(1).replace(" ", "")
|
|
else:
|
|
obj_expr_str_candidate = stripped_text_before_dot
|
|
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(("+", "-", "*", "/", "(", ",")):
|
|
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
|
|
self.logger.debug(f"Autocomplete for object. Extracted: '{obj_expr_str}' from: '{text_on_line_up_to_dot}'")
|
|
|
|
if not obj_expr_str:
|
|
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":
|
|
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:
|
|
self.logger.debug(f"SympyHelper.PopupFunctionList returned no methods.")
|
|
except Exception as 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 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():
|
|
self.logger.debug("Object expression became empty before eval. No action.")
|
|
return
|
|
self.logger.debug(f"Attempting to eval: '{obj_expr_str}'")
|
|
obj = eval(obj_expr_str, eval_context)
|
|
self.logger.debug(f"Eval successful. Object: {type(obj)}, Value: {obj}")
|
|
except Exception as e:
|
|
self.logger.debug(f"Error evaluating object expression '{obj_expr_str}': {e}")
|
|
return
|
|
|
|
# 6. Mostrar popup de autocompletado para el objeto
|
|
if obj is not None and hasattr(obj, 'PopupFunctionList'):
|
|
methods = obj.PopupFunctionList()
|
|
if methods:
|
|
self._show_autocomplete_popup(methods, is_global_popup=False)
|
|
|
|
def _show_autocomplete_popup(self, suggestions, is_global_popup=False):
|
|
"""Muestra popup de autocompletado modeless con filtrado"""
|
|
cursor_bbox = self.input_text.bbox(tk.INSERT)
|
|
if not cursor_bbox:
|
|
return
|
|
|
|
# Guardar sugerencias originales y estado
|
|
self._current_suggestions = suggestions.copy()
|
|
self._is_global_popup = is_global_popup
|
|
self._autocomplete_active = True
|
|
|
|
x, y, _, height = cursor_bbox
|
|
popup_x = self.input_text.winfo_rootx() + x
|
|
popup_y = self.input_text.winfo_rooty() + y + height + 2
|
|
|
|
# Crear popup modeless
|
|
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)
|
|
|
|
# Crear listbox
|
|
self._autocomplete_listbox = tk.Listbox(
|
|
self._autocomplete_popup,
|
|
bg="#3c3f41",
|
|
fg="#bbbbbb",
|
|
selectbackground="#007acc",
|
|
selectforeground="white",
|
|
borderwidth=1,
|
|
relief="solid",
|
|
exportselection=False,
|
|
activestyle="none"
|
|
)
|
|
|
|
# Llenar con sugerencias iniciales
|
|
self._populate_listbox(suggestions)
|
|
|
|
if suggestions:
|
|
self._autocomplete_listbox.select_set(0)
|
|
self._autocomplete_listbox.pack(expand=True, fill=tk.BOTH)
|
|
|
|
# Bindings solo para el listbox (no roba focus del input)
|
|
self._autocomplete_listbox.bind("<Double-Button-1>",
|
|
lambda e: self._select_autocomplete())
|
|
|
|
# Binding para cerrar si se hace click fuera
|
|
self.root.bind("<Button-1>", self._on_click_outside, add=True)
|
|
|
|
# Calcular tamaño
|
|
self._resize_popup()
|
|
else:
|
|
self._close_autocomplete_popup()
|
|
|
|
def _populate_listbox(self, suggestions):
|
|
"""Llena el listbox con las sugerencias"""
|
|
self._autocomplete_listbox.delete(0, tk.END)
|
|
for name, hint in suggestions:
|
|
self._autocomplete_listbox.insert(tk.END, f"{name} — {hint}")
|
|
|
|
def _resize_popup(self):
|
|
"""Redimensiona el popup según el contenido"""
|
|
if not self._autocomplete_listbox:
|
|
return
|
|
|
|
size = self._autocomplete_listbox.size()
|
|
if size == 0:
|
|
return
|
|
|
|
# Calcular dimensiones
|
|
max_len = 20
|
|
for i in range(size):
|
|
item_text = self._autocomplete_listbox.get(i)
|
|
max_len = max(max_len, len(item_text))
|
|
|
|
width = min(max_len + 5, 80)
|
|
height = min(size, 10)
|
|
|
|
self._autocomplete_listbox.config(width=width, height=height)
|
|
|
|
def _filter_autocomplete(self):
|
|
"""Filtra las sugerencias basándose en el texto escrito después del punto"""
|
|
if not self._autocomplete_active or not self._autocomplete_trigger_pos:
|
|
return
|
|
|
|
# Obtener texto escrito después del punto
|
|
current_pos = self.input_text.index(tk.INSERT)
|
|
try:
|
|
filter_text = self.input_text.get(self._autocomplete_trigger_pos, current_pos)
|
|
self._autocomplete_filter_text = filter_text.lower()
|
|
except tk.TclError:
|
|
# Posición inválida, cerrar popup
|
|
self._close_autocomplete_popup()
|
|
return
|
|
|
|
# Filtrar sugerencias
|
|
filtered = []
|
|
for name, hint in self._current_suggestions:
|
|
if name.lower().startswith(self._autocomplete_filter_text):
|
|
filtered.append((name, hint))
|
|
|
|
if filtered:
|
|
# Actualizar listbox con sugerencias filtradas
|
|
self._populate_listbox(filtered)
|
|
self._autocomplete_listbox.select_set(0)
|
|
self._resize_popup()
|
|
else:
|
|
# No hay coincidencias, cerrar popup
|
|
self._close_autocomplete_popup()
|
|
|
|
def _navigate_autocomplete_improved(self, direction):
|
|
"""Navegación mejorada en el popup de autocompletado"""
|
|
if not self._autocomplete_listbox:
|
|
return
|
|
|
|
current_selection = self._autocomplete_listbox.curselection()
|
|
size = self._autocomplete_listbox.size()
|
|
|
|
if size == 0:
|
|
return
|
|
|
|
if not current_selection:
|
|
new_idx = 0 if direction == 1 else size - 1
|
|
else:
|
|
idx = current_selection[0]
|
|
new_idx = (idx + direction) % size # Navegación circular
|
|
|
|
# Actualizar selección
|
|
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)
|
|
|
|
def _select_autocomplete(self):
|
|
"""Selecciona el item actual del autocompletado"""
|
|
if not self._autocomplete_listbox:
|
|
return
|
|
|
|
selection = self._autocomplete_listbox.curselection()
|
|
if not selection:
|
|
return
|
|
|
|
# Obtener texto seleccionado
|
|
selected_text = self._autocomplete_listbox.get(selection[0])
|
|
|
|
# Determinar si es popup de variables o funciones
|
|
is_variable_popup = self._variable_popup_active
|
|
|
|
if is_variable_popup:
|
|
# Para popup de variables, usar el método específico
|
|
self._select_variable()
|
|
return
|
|
|
|
# Para popup de funciones, extraer nombre
|
|
item_name = selected_text.split(" —")[0].strip()
|
|
is_variable = " = " in selected_text # Nuevo formato de variables
|
|
|
|
# Insertar en la posición correcta
|
|
if hasattr(self, '_is_global_popup') and self._is_global_popup:
|
|
# Para popup global, reemplazar el punto con la función
|
|
cursor_pos_str = self.input_text.index(tk.INSERT)
|
|
line_num, char_num = map(int, cursor_pos_str.split('.'))
|
|
dot_pos_on_line = char_num - len(self._autocomplete_filter_text) - 1
|
|
dot_index_str = f"{line_num}.{dot_pos_on_line}"
|
|
|
|
# Eliminar punto y texto filtrado
|
|
end_pos = f"{line_num}.{char_num}"
|
|
self.input_text.delete(dot_index_str, end_pos)
|
|
|
|
# Insertar función (no variables en popup global)
|
|
insert_text = item_name + "()"
|
|
self.input_text.insert(dot_index_str, insert_text)
|
|
self.input_text.mark_set(tk.INSERT, f"{dot_index_str}+{len(item_name)+1}c")
|
|
else:
|
|
# Para popup de objeto/variables
|
|
current_pos = self.input_text.index(tk.INSERT)
|
|
|
|
# Eliminar texto filtrado si existe
|
|
if self._autocomplete_filter_text:
|
|
start_pos = f"{current_pos}-{len(self._autocomplete_filter_text)}c"
|
|
self.input_text.delete(start_pos, current_pos)
|
|
current_pos = start_pos
|
|
|
|
# Insertar según el tipo
|
|
if is_variable:
|
|
# Solo insertar el nombre de la variable
|
|
insert_text = item_name
|
|
self.input_text.insert(current_pos, insert_text)
|
|
self.input_text.mark_set(tk.INSERT, f"{current_pos}+{len(item_name)}c")
|
|
else:
|
|
# Insertar método con paréntesis
|
|
insert_text = item_name + "()"
|
|
self.input_text.insert(current_pos, insert_text)
|
|
self.input_text.mark_set(tk.INSERT, f"{current_pos}+{len(item_name)+1}c")
|
|
|
|
# Cerrar popup y enfocar input
|
|
self._close_autocomplete_popup()
|
|
self.input_text.focus_set()
|
|
self.on_key_release()
|
|
|
|
def _select_variable(self):
|
|
"""Selecciona una variable del popup de variables"""
|
|
if not self._autocomplete_listbox:
|
|
return
|
|
|
|
selection = self._autocomplete_listbox.curselection()
|
|
if not selection:
|
|
return
|
|
|
|
# Obtener nombre de variable
|
|
selected_text = self._autocomplete_listbox.get(selection[0])
|
|
var_name = selected_text.split(" = ")[0].strip()
|
|
|
|
# Obtener posición de la palabra actual
|
|
current_line = self.input_text.get("insert linestart", "insert lineend")
|
|
cursor_pos = self.input_text.index(tk.INSERT)
|
|
line_start = self.input_text.index("insert linestart")
|
|
|
|
# Encontrar la palabra que estamos completando
|
|
words = current_line.split()
|
|
if words:
|
|
last_word = words[-1]
|
|
# Buscar posición de la última palabra
|
|
word_start_pos = current_line.rfind(last_word)
|
|
if word_start_pos >= 0:
|
|
# Calcular posición absoluta
|
|
abs_word_start = f"{line_start.split('.')[0]}.{word_start_pos}"
|
|
abs_word_end = f"{line_start.split('.')[0]}.{word_start_pos + len(last_word)}"
|
|
|
|
# Reemplazar la palabra parcial con la variable completa
|
|
self.input_text.delete(abs_word_start, abs_word_end)
|
|
self.input_text.insert(abs_word_start, var_name)
|
|
self.input_text.mark_set(tk.INSERT, f"{abs_word_start}+{len(var_name)}c")
|
|
|
|
# Cerrar popup
|
|
self._close_autocomplete_popup()
|
|
self.input_text.focus_set()
|
|
|
|
def _on_click_outside_variable(self, event):
|
|
"""Maneja clicks fuera del popup de variables"""
|
|
if self._autocomplete_popup and event.widget not in [
|
|
self._autocomplete_popup, self._autocomplete_listbox
|
|
]:
|
|
self._close_autocomplete_popup()
|
|
|
|
def _on_click_outside(self, event):
|
|
"""Maneja clicks fuera del popup"""
|
|
if self._autocomplete_popup and event.widget not in [
|
|
self._autocomplete_popup, self._autocomplete_listbox
|
|
]:
|
|
self._close_autocomplete_popup()
|
|
|
|
def _navigate_autocomplete(self, event, direction):
|
|
"""Navegación en autocomplete (mantenido por compatibilidad)"""
|
|
return self._navigate_autocomplete_improved(direction)
|
|
|
|
def _close_autocomplete_popup(self):
|
|
"""Cierra popup de autocomplete y resetea estado"""
|
|
if hasattr(self, '_autocomplete_popup') and self._autocomplete_popup:
|
|
try:
|
|
self._autocomplete_popup.destroy()
|
|
except tk.TclError:
|
|
pass # Ya fue destruido
|
|
self._autocomplete_popup = None
|
|
|
|
if hasattr(self, '_autocomplete_listbox') and self._autocomplete_listbox:
|
|
self._autocomplete_listbox = None
|
|
|
|
# Resetear estado del autocompletado
|
|
self._autocomplete_active = False
|
|
self._variable_popup_active = False
|
|
self._autocomplete_trigger_pos = None
|
|
self._autocomplete_filter_text = ""
|
|
self._current_suggestions = []
|
|
|
|
# Remover bindings temporales
|
|
try:
|
|
self.root.unbind("<Button-1>")
|
|
except tk.TclError:
|
|
pass
|
|
|
|
def _evaluate_and_update(self):
|
|
"""Evalúa todas las líneas y actualiza la salida"""
|
|
try:
|
|
input_content = self.input_text.get("1.0", tk.END)
|
|
if not input_content.strip():
|
|
self._clear_output()
|
|
return
|
|
|
|
# MODIFICADO: Solo limpiar ecuaciones, NO las variables del usuario
|
|
# Las variables deben persistir para el autocompletado
|
|
self.engine.equations.clear() # Solo limpiar ecuaciones
|
|
self.logger.debug("Ecuaciones del motor limpiadas antes de evaluación (variables mantenidas)")
|
|
|
|
lines = input_content.splitlines()
|
|
self._evaluate_lines(lines)
|
|
|
|
except Exception as e:
|
|
self._show_error(f"Error durante evaluación: {e}")
|
|
|
|
def _evaluate_lines(self, lines: List[str]):
|
|
"""Evalúa múltiples líneas de código"""
|
|
output_data = []
|
|
|
|
for line_num, line in enumerate(lines, 1):
|
|
line = line.strip()
|
|
|
|
# Líneas vacías o comentarios
|
|
if not line or line.startswith('#'):
|
|
if line:
|
|
output_data.append([("comment", line)])
|
|
else:
|
|
output_data.append([("", "")])
|
|
continue
|
|
|
|
# Evaluar línea
|
|
result = self.engine.evaluate_line(line)
|
|
line_output = self._process_evaluation_result(result)
|
|
output_data.append(line_output)
|
|
|
|
self._display_output(output_data)
|
|
|
|
def _process_evaluation_result(self, result: EvaluationResult) -> List[tuple]:
|
|
"""Procesa el resultado de evaluación para display, priorizando resultados interactivos."""
|
|
output_parts = []
|
|
indicator_text: Optional[str] = None
|
|
|
|
if result.result_type == "comment":
|
|
output_parts.append(("comment", result.output if result.output is not None else ""))
|
|
return output_parts
|
|
|
|
if not result.success:
|
|
# Manejo de errores
|
|
error_prefix = "Error: "
|
|
main_error_message = f"{error_prefix}{result.error_message}"
|
|
|
|
# Intentar obtener ayuda contextual para el error
|
|
ayuda_text = self.obtener_ayuda(result.input_line) # obtener_ayuda devuelve string o None
|
|
if ayuda_text:
|
|
ayuda_linea = ayuda_text.replace("\n", " ").replace("\r", " ").strip()
|
|
if len(ayuda_linea) > 120: # Acortar si es muy larga
|
|
ayuda_linea = ayuda_linea[:117] + "..."
|
|
|
|
# Mostrar primero el error principal, luego la sugerencia
|
|
output_parts.append(("error", main_error_message))
|
|
output_parts.append( ("\n", "\n") ) # Separador para la ayuda
|
|
output_parts.append(("helper", f"Sugerencia: {ayuda_linea}"))
|
|
else:
|
|
output_parts.append(("error", main_error_message))
|
|
# No se añade type_indicator para errores aquí, el mensaje de error es suficiente.
|
|
|
|
else:
|
|
# RESULTADO EXITOSO:
|
|
# 1. Intentar crear un tag interactivo
|
|
interactive_tag_info = self.interactive_manager.create_interactive_tag(
|
|
result.actual_result_object,
|
|
self.output_text
|
|
)
|
|
|
|
if interactive_tag_info:
|
|
tag_name, display_text = interactive_tag_info
|
|
output_parts.append((tag_name, display_text))
|
|
|
|
# Añadir también el indicador de tipo algebraico
|
|
if result.algebraic_type:
|
|
indicator_text = f"[{result.algebraic_type}]"
|
|
output_parts.append((" ", " "))
|
|
output_parts.append(("type_indicator", indicator_text))
|
|
else:
|
|
# 2. Si no es interactivo, usar la lógica de formato de texto anterior
|
|
main_output_tag = "base"
|
|
|
|
if result.is_assignment:
|
|
main_output_tag = "assignment"
|
|
indicator_text = "[=]"
|
|
elif result.is_equation:
|
|
main_output_tag = "equation"
|
|
indicator_text = "[eq]"
|
|
elif result.result_type == "plot":
|
|
main_output_tag = "plot"
|
|
# Este caso es un fallback si create_interactive_tag no lo manejó
|
|
else:
|
|
# Lógica para determinar el tag principal y el texto del indicador
|
|
if result.algebraic_type:
|
|
type_lower = result.algebraic_type.lower()
|
|
if type_lower in self.output_text.tag_names():
|
|
main_output_tag = type_lower
|
|
elif isinstance(result.actual_result_object, sympy.Basic):
|
|
main_output_tag = "symbolic"
|
|
elif type_lower in ["int", "float", "complex"] or isinstance(result.actual_result_object, (int, float)):
|
|
main_output_tag = "numeric"
|
|
elif type_lower == "bool" or isinstance(result.actual_result_object, bool):
|
|
main_output_tag = "boolean"
|
|
elif type_lower == "str" or isinstance(result.actual_result_object, str):
|
|
main_output_tag = "string"
|
|
elif result.actual_result_object is not None and \
|
|
not isinstance(result.actual_result_object, (sympy.Basic, int, float, bool, str, list, dict, tuple, type(None))):
|
|
main_output_tag = "custom_type"
|
|
else:
|
|
main_output_tag = "symbolic"
|
|
else:
|
|
main_output_tag = "symbolic"
|
|
|
|
if result.algebraic_type:
|
|
is_collection = any(kw in result.algebraic_type.lower() for kw in ["matrix", "list", "dict", "tuple", "vector", "array"])
|
|
is_custom_obj_tag = (main_output_tag == "custom_type")
|
|
is_non_trivial_sympy = isinstance(result.actual_result_object, sympy.Basic) and \
|
|
result.algebraic_type not in ["Symbol", "Expr", "Integer", "Float", "Rational", "BooleanTrue", "BooleanFalse"]
|
|
if is_collection or is_custom_obj_tag or is_non_trivial_sympy:
|
|
indicator_text = f"[{result.algebraic_type}]"
|
|
|
|
output_parts.append((main_output_tag, result.output if result.output is not None else ""))
|
|
|
|
if indicator_text:
|
|
output_parts.append((" ", " "))
|
|
output_parts.append(("type_indicator", indicator_text))
|
|
|
|
return output_parts
|
|
|
|
def _get_result_tag_dynamic(self, result: Any) -> str:
|
|
"""Determina el tag de color para un resultado - SIMPLIFICADO"""
|
|
# Determinar tag basado en tipo
|
|
if hasattr(result, '__class__'):
|
|
class_name = result.__class__.__name__.lower()
|
|
if 'hex' in class_name:
|
|
return "hex"
|
|
elif 'bin' in class_name:
|
|
return "bin"
|
|
elif 'ip' in class_name:
|
|
return "ip"
|
|
elif 'chr' in class_name:
|
|
return "chr_type"
|
|
elif 'date' in class_name:
|
|
return "date"
|
|
|
|
# Fallback a tags existentes
|
|
try:
|
|
import sympy
|
|
if isinstance(result, sympy.Basic):
|
|
return "symbolic"
|
|
except:
|
|
pass
|
|
|
|
return "result"
|
|
|
|
def _get_class_display_name_dynamic(self, obj: Any) -> str:
|
|
"""Obtiene nombre de clase para display - SIMPLIFICADO"""
|
|
try:
|
|
import sympy
|
|
if isinstance(obj, sympy.Basic):
|
|
return "Sympy"
|
|
except:
|
|
pass
|
|
|
|
if isinstance(obj, (int, float, str, list, dict, tuple, bool, type(None))):
|
|
class_display_name = type(obj).__name__.capitalize()
|
|
if class_display_name == "Nonetype":
|
|
class_display_name = "None"
|
|
return class_display_name
|
|
|
|
return type(obj).__name__
|
|
|
|
def _display_output(self, output_data: List[List[tuple]]):
|
|
"""Muestra los datos de salida en el widget (sin cambios)"""
|
|
self.output_text.config(state="normal")
|
|
self.output_text.delete("1.0", tk.END)
|
|
|
|
for line_idx, line_parts in enumerate(output_data):
|
|
if not line_parts or (len(line_parts) == 1 and line_parts[0][0] == "" and line_parts[0][1] == ""):
|
|
pass
|
|
else:
|
|
for part_idx, (tag, content) in enumerate(line_parts):
|
|
if not content:
|
|
continue
|
|
|
|
if part_idx > 0:
|
|
prev_tag, prev_content = line_parts[part_idx-1] if part_idx > 0 else (None, None)
|
|
|
|
if tag not in ["class_hint", "numeric", "info"] and prev_content:
|
|
self.output_text.insert(tk.END, " ; ")
|
|
elif tag in ["numeric", "info"] and prev_content:
|
|
self.output_text.insert(tk.END, " ")
|
|
|
|
if content:
|
|
self.output_text.insert(tk.END, str(content), tag)
|
|
|
|
if line_idx < len(output_data) - 1:
|
|
self.output_text.insert(tk.END, "\n")
|
|
|
|
self.output_text.config(state="disabled")
|
|
|
|
def _clear_output(self):
|
|
"""Limpia el panel de salida"""
|
|
self.output_text.config(state="normal")
|
|
self.output_text.delete("1.0", tk.END)
|
|
self.output_text.config(state="disabled")
|
|
|
|
def _show_error(self, error_msg: str):
|
|
"""Muestra un error en el panel de salida"""
|
|
self.output_text.config(state="normal")
|
|
self.output_text.delete("1.0", tk.END)
|
|
self.output_text.insert("1.0", error_msg, "error")
|
|
self.output_text.config(state="disabled")
|
|
|
|
def _show_context_menu(self, event, panel_type: str):
|
|
"""Muestra menú contextual"""
|
|
context_menu = Menu(
|
|
self.root, tearoff=0, bg="#3c3c3c", fg="white",
|
|
activebackground="#007acc", activeforeground="white",
|
|
relief=tk.FLAT, bd=1,
|
|
)
|
|
|
|
if panel_type == "input":
|
|
context_menu.add_command(label="Cortar", command=lambda: self.input_text.event_generate("<<Cut>>"))
|
|
context_menu.add_command(label="Copiar", command=lambda: self.input_text.event_generate("<<Copy>>"))
|
|
context_menu.add_command(label="Pegar", command=lambda: self.input_text.event_generate("<<Paste>>"))
|
|
context_menu.add_separator()
|
|
context_menu.add_command(label="Limpiar entrada", command=self.clear_input)
|
|
elif panel_type == "output":
|
|
context_menu.add_command(label="Copiar todo", command=self.copy_output)
|
|
context_menu.add_command(label="Limpiar salida", command=self.clear_output)
|
|
|
|
context_menu.add_separator()
|
|
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()
|
|
|
|
# ========== MÉTODOS DE MENÚ Y COMANDOS (la mayoría sin cambios) ==========
|
|
|
|
def new_session(self):
|
|
"""Inicia nueva sesión"""
|
|
self.clear_input()
|
|
self.clear_output()
|
|
self.engine.clear_context() # Aquí sí limpiamos todo porque es nueva sesión
|
|
|
|
def load_file(self):
|
|
"""Carga archivo en el editor"""
|
|
filepath = filedialog.askopenfilename(
|
|
title="Cargar archivo",
|
|
filetypes=[
|
|
("Archivos de texto", "*.txt"),
|
|
("Archivos Python", "*.py"),
|
|
("Todos los archivos", "*.*")
|
|
]
|
|
)
|
|
|
|
if filepath:
|
|
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()
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"No se pudo cargar el archivo:\n{e}")
|
|
|
|
def save_file(self):
|
|
"""Guarda contenido del editor"""
|
|
filepath = filedialog.asksaveasfilename(
|
|
title="Guardar archivo",
|
|
defaultextension=".txt",
|
|
filetypes=[
|
|
("Archivos de texto", "*.txt"),
|
|
("Archivos Python", "*.py"),
|
|
("Todos los archivos", "*.*")
|
|
]
|
|
)
|
|
|
|
if filepath:
|
|
try:
|
|
content = self.input_text.get("1.0", tk.END)
|
|
with open(filepath, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
messagebox.showinfo("Éxito", "Archivo guardado correctamente.")
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"No se pudo guardar el archivo:\n{e}")
|
|
|
|
def clear_input(self):
|
|
"""Limpia panel de entrada"""
|
|
self.input_text.delete("1.0", tk.END)
|
|
self._clear_output()
|
|
|
|
def clear_output(self):
|
|
"""Limpia panel de salida"""
|
|
self._clear_output()
|
|
|
|
def clear_history(self):
|
|
"""Limpia el archivo de historial"""
|
|
try:
|
|
if os.path.exists(self.HISTORY_FILE):
|
|
os.remove(self.HISTORY_FILE)
|
|
messagebox.showinfo("Éxito", "Historial limpiado correctamente.")
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"No se pudo limpiar el historial:\n{e}")
|
|
|
|
def copy_output(self):
|
|
"""Copia el contenido de la salida al portapapeles"""
|
|
content = self.output_text.get("1.0", tk.END).strip()
|
|
if content:
|
|
self.root.clipboard_clear()
|
|
self.root.clipboard_append(content)
|
|
|
|
def show_types_syntax(self):
|
|
"""Muestra sintaxis de tipos disponibles - NUEVA FUNCIÓN"""
|
|
try:
|
|
types_info = self.engine.get_available_types()
|
|
registered_classes = types_info.get('registered_classes', {})
|
|
|
|
syntax_text = "SINTAXIS DE TIPOS DISPONIBLES\n\n"
|
|
|
|
if not registered_classes:
|
|
syntax_text += "No hay tipos personalizados disponibles.\n"
|
|
else:
|
|
syntax_text += "Tipos personalizados detectados:\n\n"
|
|
|
|
for name, cls in sorted(registered_classes.items()):
|
|
syntax_text += f"=== {name} ===\n"
|
|
|
|
# Sintaxis básica
|
|
syntax_text += f"Sintaxis: {name}[valor]\n"
|
|
syntax_text += f"Alias: {name.lower()}[valor]\n"
|
|
|
|
# Obtener ayuda si está disponible
|
|
if hasattr(cls, 'Helper'):
|
|
try:
|
|
help_text = cls.Helper(name)
|
|
if help_text:
|
|
syntax_text += f"Ayuda: {help_text}\n"
|
|
except:
|
|
pass
|
|
|
|
# Obtener métodos si está disponible
|
|
if hasattr(cls, 'PopupFunctionList'):
|
|
try:
|
|
methods = cls.PopupFunctionList()
|
|
if methods:
|
|
syntax_text += "Métodos disponibles:\n"
|
|
for method_name, method_desc in methods:
|
|
syntax_text += f" • {method_name}() - {method_desc}\n"
|
|
except:
|
|
pass
|
|
|
|
syntax_text += "\n"
|
|
|
|
self._show_help_window("Sintaxis de Tipos", syntax_text)
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Error obteniendo sintaxis de tipos:\n{e}")
|
|
|
|
def show_quick_guide(self):
|
|
"""Muestra guía rápida - ACTUALIZADA"""
|
|
guide = """# Calculadora MAV - CAS Híbrido
|
|
|
|
## Sistema de Tipos Dinámico
|
|
El sistema detecta automáticamente tipos disponibles en custom_types/
|
|
|
|
## Sintaxis Nueva con Corchetes
|
|
- Sintaxis: Tipo[valor] en lugar de Tipo("valor")
|
|
- Ejemplos: Hex[FF], Bin[1010], Dec[10.5], Chr[A]
|
|
- Use menú Tipos → Información de tipos para ver tipos disponibles
|
|
|
|
## Ecuaciones Automáticas
|
|
- x**2 + 2*x = 8 (detectado automáticamente)
|
|
- a + b = 10 (agregado al sistema)
|
|
- variable=? (atajo para solve(variable))
|
|
|
|
## Funciones SymPy Disponibles
|
|
- solve(), diff(), integrate(), limit(), series()
|
|
- sin(), cos(), tan(), exp(), log(), sqrt()
|
|
- Matrix(), plot(), plot3d()
|
|
|
|
## Resultados Interactivos
|
|
- 📊 Ver Plot (click para ventana matplotlib)
|
|
- 📋 Ver Matriz (click para vista expandida)
|
|
- 📋 Ver Lista (click para contenido completo)
|
|
|
|
## Variables Automáticas
|
|
- Todas las variables son símbolos SymPy
|
|
- x = 5 crea Symbol('x') con valor 5
|
|
- Evaluación simbólica + numérica automática
|
|
|
|
## Autocompletado Dinámico
|
|
- Escriba "." después de cualquier objeto para ver métodos
|
|
- El sistema usa los tipos registrados automáticamente
|
|
"""
|
|
|
|
self._show_help_window("Guía Rápida", guide)
|
|
|
|
def show_syntax_help(self):
|
|
"""Muestra ayuda de sintaxis - ACTUALIZADA"""
|
|
syntax = """# Sintaxis del CAS Híbrido
|
|
|
|
## Sistema de Tipos Dinámico
|
|
Los tipos se detectan automáticamente desde custom_types/
|
|
Use menú Tipos → Información de tipos para ver tipos disponibles
|
|
|
|
## Sintaxis con Corchetes (Dinámica)
|
|
Tipo[valor] # Sintaxis general
|
|
Tipo[arg1; arg2] # Múltiples argumentos
|
|
|
|
## Métodos Disponibles (Dinámicos)
|
|
Tipo[...].método() # Métodos específicos del tipo
|
|
objeto.método[] # Método sin argumentos
|
|
|
|
## Ecuaciones (detección automática)
|
|
expresión = expresión # Ecuación simple
|
|
expresión == expresión # Igualdad SymPy
|
|
expresión > expresión # Desigualdad SymPy
|
|
|
|
## Resolver
|
|
solve(ecuación, variable)
|
|
variable=? # Atajo para solve(variable)
|
|
|
|
## Variables SymPy Puras
|
|
x = valor # Crea Symbol('x')
|
|
expresión # Evaluación simbólica automática
|
|
"""
|
|
|
|
self._show_help_window("Sintaxis", syntax)
|
|
|
|
def show_sympy_functions(self):
|
|
"""Muestra funciones SymPy disponibles (sin cambios)"""
|
|
functions = """# Funciones SymPy Disponibles
|
|
|
|
## Matemáticas Básicas
|
|
sin(x), cos(x), tan(x)
|
|
asin(x), acos(x), atan(x)
|
|
sinh(x), cosh(x), tanh(x)
|
|
exp(x), log(x), sqrt(x)
|
|
abs(x), sign(x), factorial(x)
|
|
|
|
## Cálculo
|
|
diff(expr, var) # Derivada
|
|
integrate(expr, var) # Integral indefinida
|
|
integrate(expr, (var, a, b)) # Integral definida
|
|
limit(expr, var, punto) # Límite
|
|
series(expr, var, punto, n) # Serie de Taylor
|
|
|
|
## Álgebra
|
|
solve(ecuación, variable)
|
|
simplify(expr), expand(expr)
|
|
factor(expr), collect(expr, var)
|
|
cancel(expr), apart(expr, var)
|
|
|
|
## Álgebra Lineal
|
|
Matrix([[a, b], [c, d]])
|
|
det(matrix), inv(matrix)
|
|
|
|
## Plotting
|
|
plot(expr, (var, inicio, fin))
|
|
plot3d(expr, (x, x1, x2), (y, y1, y2))
|
|
|
|
## Constantes
|
|
pi, E, I (imaginario), oo (infinito)
|
|
"""
|
|
|
|
self._show_help_window("Funciones SymPy", functions)
|
|
|
|
def show_about(self):
|
|
"""Muestra información sobre la aplicación - ACTUALIZADA"""
|
|
about = """Calculadora MAV - CAS Híbrido
|
|
|
|
Versión: 2.1 (Sistema de Tipos Dinámico)
|
|
Motor: SymPy + Auto-descubrimiento de Tipos
|
|
|
|
Características:
|
|
• Motor algebraico completo (SymPy)
|
|
• Sistema de tipos dinámico y extensible
|
|
• Sintaxis simplificada con corchetes
|
|
• Detección automática de ecuaciones
|
|
• Resultados interactivos clickeables
|
|
• Auto-descubrimiento de tipos en custom_types/
|
|
• Variables SymPy puras
|
|
• Plotting integrado
|
|
• Autocompletado dinámico
|
|
|
|
NUEVO: Sistema de Tipos Dinámico
|
|
• Detección automática de nuevos tipos
|
|
• Organización modular en custom_types/
|
|
• Registro automático sin modificar código
|
|
• Escalabilidad mejorada
|
|
|
|
Desarrollado para cálculo matemático avanzado
|
|
con soporte especializado para redes,
|
|
programación y análisis numérico.
|
|
"""
|
|
|
|
messagebox.showinfo("Acerca de", about)
|
|
|
|
def _show_help_window(self, title: str, content: str):
|
|
"""Muestra ventana de ayuda"""
|
|
window = tk.Toplevel(self.root)
|
|
window.title(title)
|
|
window.geometry("700x500")
|
|
window.configure(bg="#2b2b2b")
|
|
|
|
text_widget = scrolledtext.ScrolledText(
|
|
window,
|
|
font=("Consolas", 10),
|
|
bg="#1e1e1e",
|
|
fg="#d4d4d4",
|
|
wrap=tk.WORD
|
|
)
|
|
text_widget.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
|
|
text_widget.insert("1.0", content)
|
|
text_widget.config(state="disabled")
|
|
|
|
def load_history(self):
|
|
"""Carga historial de entrada y realiza evaluación inicial"""
|
|
try:
|
|
if os.path.exists(self.HISTORY_FILE):
|
|
with open(self.HISTORY_FILE, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
if content.strip():
|
|
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._process_input_and_adjust_layout)
|
|
except Exception as e:
|
|
self.logger.error(f"Error cargando historial: {e}", exc_info=True)
|
|
|
|
def save_history(self):
|
|
"""Guarda historial de entrada"""
|
|
try:
|
|
content = self.input_text.get("1.0", tk.END).rstrip("\n")
|
|
if content:
|
|
with open(self.HISTORY_FILE, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
elif os.path.exists(self.HISTORY_FILE):
|
|
os.remove(self.HISTORY_FILE)
|
|
except Exception as e:
|
|
self.logger.error(f"Error guardando historial: {e}", exc_info=True)
|
|
|
|
def on_close(self):
|
|
"""Maneja cierre de la aplicación de forma completa"""
|
|
try:
|
|
# Guardar historial y configuraciones
|
|
self.save_history()
|
|
self._save_settings()
|
|
|
|
# Cerrar todas las ventanas interactivas
|
|
if self.interactive_manager:
|
|
self.interactive_manager.close_all_windows()
|
|
|
|
# Detener cualquier job pendiente
|
|
if self._debounce_job:
|
|
self.root.after_cancel(self._debounce_job)
|
|
|
|
# Cerrar autocompletado si está abierto
|
|
self._close_autocomplete_popup()
|
|
|
|
# Asegurar que matplotlib libere recursos
|
|
try:
|
|
import matplotlib.pyplot as plt
|
|
plt.close('all')
|
|
except:
|
|
pass
|
|
|
|
# Forzar la salida del mainloop
|
|
self.root.quit()
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error durante el cierre: {e}")
|
|
finally:
|
|
# Destruir la ventana principal como último recurso
|
|
try:
|
|
self.root.destroy()
|
|
except:
|
|
pass
|
|
|
|
def show_help_window(self):
|
|
"""Muestra ventana de ayuda con archivo externo - NUEVO SISTEMA"""
|
|
help_win = tk.Toplevel(self.root)
|
|
help_win.title("Ayuda - Calculadora MAV CAS Híbrido")
|
|
help_win.geometry("750x600")
|
|
help_win.configure(bg="#1e1e1e")
|
|
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
|
|
dark_theme_css = """
|
|
<style>
|
|
h1, h2, h3, h4, h5, h6 {
|
|
color: #569cd6;
|
|
margin-top: 1.2em;
|
|
margin-bottom: 0.6em;
|
|
border-bottom: 1px solid #3a3a3a;
|
|
padding-bottom: 0.3em;
|
|
}
|
|
h1 { font-size: 1.8em; }
|
|
h2 { font-size: 1.5em; }
|
|
h3 { font-size: 1.3em; }
|
|
p {
|
|
line-height: 1.65;
|
|
margin-bottom: 0.8em;
|
|
}
|
|
a {
|
|
color: #4fc3f7;
|
|
text-decoration: none;
|
|
}
|
|
a:hover {
|
|
color: #80dfff;
|
|
text-decoration: underline;
|
|
}
|
|
code {
|
|
font-family: "Consolas", "Courier New", monospace;
|
|
background-color: #2d2d2d;
|
|
color: #ce9178;
|
|
padding: 3px 6px;
|
|
border-radius: 4px;
|
|
border: 1px solid #3c3c3c;
|
|
font-size: 0.95em;
|
|
}
|
|
pre {
|
|
background-color: #1e1e1e;
|
|
border: 1px solid #3c3c3c;
|
|
border-radius: 4px;
|
|
padding: 12px;
|
|
overflow-x: auto;
|
|
margin: 1em 0;
|
|
}
|
|
pre > code {
|
|
background-color: transparent !important;
|
|
color: inherit !important;
|
|
padding: 0 !important;
|
|
border-radius: 0 !important;
|
|
border: none !important;
|
|
font-size: 1em !important;
|
|
}
|
|
ul, ol { padding-left: 25px; color: #c8c8c8; }
|
|
li { margin-bottom: 0.4em; }
|
|
hr { border: 0; height: 1px; background: #3a3a3a; margin: 1.5em 0; }
|
|
table { border-collapse: collapse; width: 90%; margin: 1em auto; }
|
|
th, td { border: 1px solid #4a4a4a; padding: 8px; text-align: left; }
|
|
th { background-color: #2d2d2d; color: #9cdcfe; font-weight: bold; }
|
|
td { background-color: #1e1e1e; }
|
|
blockquote {
|
|
border-left: 4px solid #569cd6;
|
|
margin: 1em 0;
|
|
padding: 0.5em 1em;
|
|
background-color: #2d2d30;
|
|
font-style: italic;
|
|
}
|
|
</style>
|
|
"""
|
|
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"""
|
|
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Ayuda de Calculadora</title>
|
|
{dark_theme_css}
|
|
</head>
|
|
<body style="background-color: #1e1e1e; color: #d4d4d4; font-family: 'Segoe UI', sans-serif; font-size: 10pt; margin:0; padding:12px;">
|
|
{html_fragment}
|
|
</body>
|
|
</html>
|
|
"""
|
|
if HTML_VIEWER_TYPE == "tkinterweb":
|
|
html_viewer = tkinterweb.HtmlFrame(help_win, messages_enabled=False)
|
|
html_viewer.load_html(content_for_viewer)
|
|
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)
|
|
except Exception as 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:
|
|
self._show_text_help(help_win, readme_content)
|
|
|
|
# Botón de cerrar
|
|
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 archivo externo o genera uno por defecto"""
|
|
try:
|
|
if os.path.exists(self.HELP_FILE):
|
|
with open(self.HELP_FILE, "r", encoding="utf-8") as f:
|
|
return f.read()
|
|
except IOError:
|
|
pass
|
|
|
|
# Contenido por defecto si no se encuentra el archivo
|
|
return """# Calculadora MAV - CAS Híbrido
|
|
|
|
## Sistema de Tipos Dinámico
|
|
El sistema detecta automáticamente tipos disponibles en `custom_types/`
|
|
|
|
## Sintaxis Nueva con Corchetes
|
|
- **Sintaxis**: `Tipo[valor]` en lugar de `Tipo("valor")`
|
|
- **Ejemplos**: `Hex[FF]`, `Bin[1010]`, `Dec[10.5]`, `Chr[A]`
|
|
- Use menú **Tipos → Información de tipos** para ver tipos disponibles
|
|
|
|
## Ecuaciones Automáticas
|
|
- `x**2 + 2*x = 8` (detectado automáticamente)
|
|
- `a + b = 10` (agregado al sistema)
|
|
- `variable=?` (atajo para solve(variable))
|
|
|
|
## Funciones SymPy Disponibles
|
|
- `solve()`, `diff()`, `integrate()`, `limit()`, `series()`
|
|
- `sin()`, `cos()`, `tan()`, `exp()`, `log()`, `sqrt()`
|
|
- `Matrix()`, `plot()`, `plot3d()`
|
|
|
|
## Resultados Interactivos
|
|
- 📊 **Ver Plot** (click para ventana matplotlib)
|
|
- 📋 **Ver Matriz** (click para vista expandida)
|
|
- 📋 **Ver Lista** (click para contenido completo)
|
|
|
|
## Variables Automáticas
|
|
- Todas las variables son símbolos SymPy
|
|
- `x = 5` crea Symbol('x') con valor 5
|
|
- Evaluación simbólica + numérica automática
|
|
|
|
## Autocompletado Dinámico
|
|
- Escriba "." después de cualquier objeto para ver métodos
|
|
- El sistema usa los tipos registrados automáticamente
|
|
|
|
## Menú Contextual (clic derecho)
|
|
- **En entrada**: Cortar, Copiar, Pegar, Limpiar entrada, Ayuda
|
|
- **En salida**: Copiar todo, Limpiar salida, Ayuda
|
|
|
|
## Desarrollo
|
|
Para crear un archivo de ayuda personalizado, cree un archivo `readme.md` en el directorio raíz de la aplicación.
|
|
"""
|
|
|
|
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 obtener_ayuda(self, input_str):
|
|
"""Obtiene ayuda usando helpers dinámicos"""
|
|
for helper in self.HELPERS:
|
|
try:
|
|
ayuda = helper(input_str)
|
|
if ayuda:
|
|
return ayuda
|
|
except Exception as e:
|
|
self.logger.debug(f"Error en helper: {e}")
|
|
continue
|
|
return None
|
|
|
|
|
|
|
|
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 _update_input_expression(self, original_expression, new_expression):
|
|
"""Actualiza el panel de entrada reemplazando la expresión original con la nueva"""
|
|
try:
|
|
# Obtener todo el contenido actual
|
|
current_content = self.input_text.get("1.0", tk.END).rstrip('\n')
|
|
|
|
# Buscar y reemplazar la expresión original
|
|
if original_expression in current_content:
|
|
updated_content = current_content.replace(original_expression, new_expression, 1)
|
|
|
|
# Actualizar el panel de entrada
|
|
self.input_text.delete("1.0", tk.END)
|
|
self.input_text.insert("1.0", updated_content)
|
|
|
|
# Evaluar automáticamente la nueva expresión
|
|
self._evaluate_and_update()
|
|
|
|
self.logger.info(f"Expresión actualizada: '{original_expression}' -> '{new_expression}'")
|
|
else:
|
|
# Si no se encuentra la expresión original, agregar la nueva al final
|
|
if current_content and not current_content.endswith('\n'):
|
|
current_content += '\n'
|
|
updated_content = current_content + new_expression
|
|
|
|
self.input_text.delete("1.0", tk.END)
|
|
self.input_text.insert("1.0", updated_content)
|
|
|
|
self.logger.info(f"Expresión agregada: '{new_expression}'")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error actualizando expresión: {e}")
|
|
# Fallback: simplemente insertar la nueva expresión
|
|
self.input_text.insert(tk.END, f"\n{new_expression}")
|
|
|
|
|
|
def main():
|
|
"""Función principal"""
|
|
root = tk.Tk()
|
|
app = HybridCalculatorApp(root)
|
|
|
|
try:
|
|
root.iconname("Calculadora MAV CAS")
|
|
except tk.TclError:
|
|
pass
|
|
|
|
root.mainloop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |