From efbc6a5b5232bc43d5fee8a9e43eefdea52fc27d Mon Sep 17 00:00:00 2001 From: Miguel Date: Mon, 9 Jun 2025 22:07:11 +0200 Subject: [PATCH] Version con Pyside basica --- hybrid_calc_history.txt | 12 +- hybrid_calc_settings.json | 13 +- main_calc_app_pyside6.py | 3195 +++++++++++++++++++------------------ 3 files changed, 1626 insertions(+), 1594 deletions(-) diff --git a/hybrid_calc_history.txt b/hybrid_calc_history.txt index 09bb767..6c06043 100644 --- a/hybrid_calc_history.txt +++ b/hybrid_calc_history.txt @@ -1,8 +1,8 @@ -# Calculo de Horas -horas=72 -t=36 -horas*t -t=sqrt((y*8-25))/e +$$Brix = \frac{Brix_{syrup} \cdot \delta_{syrup} + (Brix_{water} \cdot \delta_{water} \cdot Rateo)}{\delta_{syrup} + \delta_{water} \cdot Rateo}$$ + + +m=((sqrt(t)/(2+8))/e**2+sqrt(k**w))/((sqrt(t)/(2+8))/e**2+sqrt(k**(w+2))) + +t=? -t=? \ No newline at end of file diff --git a/hybrid_calc_settings.json b/hybrid_calc_settings.json index 95cdd57..ad4041a 100644 --- a/hybrid_calc_settings.json +++ b/hybrid_calc_settings.json @@ -1,6 +1,15 @@ { - "window_geometry": "1400x800+52+52", + "window_geometry": { + "x": 393, + "y": 234, + "width": 1000, + "height": 700 + }, "debug_mode": false, "latex_panel_visible": true, - "sash_pos_x": 450 + "sash_pos_x": 450, + "splitter_sizes": [ + 284, + 210 + ] } \ No newline at end of file diff --git a/main_calc_app_pyside6.py b/main_calc_app_pyside6.py index 06b26c2..b06e34a 100644 --- a/main_calc_app_pyside6.py +++ b/main_calc_app_pyside6.py @@ -1,31 +1,31 @@ """ -Calculadora MAV CAS Híbrida - Aplicación PySide6 con MathJax -Diseño minimalista de 3 paneles con correspondencia 1:1 línea por línea +Calculadora MAV CAS Híbrida - Aplicación principal PySide6 +VERSIÓN COMPLETA: Preserva TODA la funcionalidad de la versión tkinter """ import sys import json import logging import os import re -import threading import time from pathlib import Path -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional, Tuple from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QPlainTextEdit, QSplitter, QPushButton, QLabel, - QFrame, QMenuBar, QStatusBar, QMessageBox, QFileDialog, - QScrollArea, QSizePolicy, QListWidget, QListWidgetItem, - QCompleter, QAbstractItemView + QFrame, QMenuBar, QMenu, QStatusBar, QMessageBox, QFileDialog, + QListWidget, QListWidgetItem, QTextBrowser, QScrollArea, + QDockWidget, QToolBar ) from PySide6.QtCore import ( - Qt, QTimer, QThread, QObject, Signal, QUrl, QSize + Qt, QTimer, Signal, QUrl, QSize, QRect, QPoint, Slot, + QPropertyAnimation, QEasingCurve, QEvent ) from PySide6.QtGui import ( QFont, QTextCursor, QTextCharFormat, QColor, QIcon, - QTextDocument, QSyntaxHighlighter, QTextFormat, - QKeySequence, QShortcut, QFontMetrics + QSyntaxHighlighter, QTextDocument, QKeySequence, + QShortcut, QFontMetrics, QPalette, QTextOption, QAction ) from PySide6.QtWebEngineWidgets import QWebEngineView from PySide6.QtWebEngineCore import QWebEngineSettings @@ -38,102 +38,170 @@ import sympy from sympy_helper import SympyTools as SympyHelper -class MathInputHighlighter(QSyntaxHighlighter): - """Resaltador de sintaxis para expresiones matemáticas""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setup_highlighting_rules() - - def setup_highlighting_rules(self): - """Configura las reglas de resaltado""" - self.highlighting_rules = [] - - # Números - number_format = QTextCharFormat() - number_format.setForeground(QColor("#b5cea8")) - self.highlighting_rules.append((r'\b\d+\.?\d*\b', number_format)) - - # Funciones matemáticas - function_format = QTextCharFormat() - function_format.setForeground(QColor("#dcdcaa")) - functions = [ - 'sin', 'cos', 'tan', 'log', 'ln', 'exp', 'sqrt', 'abs', - 'diff', 'integrate', 'limit', 'sum', 'product', 'solve' - ] - for func in functions: - self.highlighting_rules.append((rf'\b{func}\b', function_format)) - - # Variables y constantes - variable_format = QTextCharFormat() - variable_format.setForeground(QColor("#9cdcfe")) - self.highlighting_rules.append((r'\b[a-zA-Z_][a-zA-Z0-9_]*\b', variable_format)) - - # Operadores - operator_format = QTextCharFormat() - operator_format.setForeground(QColor("#d4d4d4")) - self.highlighting_rules.append((r'[\+\-\*\/\=\^\(\)]', operator_format)) - - # Comentarios - comment_format = QTextCharFormat() - comment_format.setForeground(QColor("#6a9955")) - comment_format.setFontItalic(True) - self.highlighting_rules.append((r'#.*', comment_format)) - - def highlightBlock(self, text): - """Aplica el resaltado a un bloque de texto""" - for pattern, format_obj in self.highlighting_rules: - import re - for match in re.finditer(pattern, text): - start, end = match.span() - self.setFormat(start, end - start, format_obj) - - -class SynchronizedTextEdit(QTextEdit): - """QTextEdit que puede sincronizar scroll con otro widget""" - - def __init__(self, parent=None): - super().__init__(parent) - self.sync_target = None - - def sync_scroll_with(self, other_widget): - """Configura sincronización de scroll""" - self.sync_target = other_widget - - -class LineNumberedPlainTextEdit(QPlainTextEdit): - """QPlainTextEdit básico para entrada""" +class InputTextEdit(QPlainTextEdit): + """Editor de texto personalizado con eventos mejorados""" def __init__(self, parent=None): super().__init__(parent) + self.parent_app = parent self.setLineWrapMode(QPlainTextEdit.NoWrap) + self.setFont(QFont("Consolas", 11)) + + def keyPressEvent(self, event): + """Override para manejar autocompletado""" + if hasattr(self.parent_app, '_handle_key_press'): + # Dejar que el parent maneje primero para autocompletado + if self.parent_app._handle_key_press(event): + return + super().keyPressEvent(event) -class MathJaxPanel(QWebEngineView): - """Panel MathJax mejorado con mejor parsing de LaTeX""" +class OutputTextEdit(QTextEdit): + """Editor de salida con soporte para links clickeables""" + + link_clicked = Signal(str, object) # link_id, result_object + + def __init__(self, parent=None): + super().__init__(parent) + self.setReadOnly(True) + self.setFont(QFont("Consolas", 11)) + self.clickable_links = {} # {(start, end): (link_id, object)} + + def mousePressEvent(self, event): + """Detecta clicks en links""" + if event.button() == Qt.LeftButton: + cursor = self.cursorForPosition(event.pos()) + pos = cursor.position() + + # Buscar si el click fue en un link + for (start, end), (link_id, obj) in self.clickable_links.items(): + if start <= pos <= end: + self.link_clicked.emit(link_id, obj) + return + + super().mousePressEvent(event) + + +class ExpandableLatexButton(QPushButton): + """Botón expandible para mostrar/ocultar panel LaTeX""" + + def __init__(self, parent=None): + super().__init__("📐", parent) + self.setFixedWidth(25) + self.setToolTip("Mostrar/ocultar panel LaTeX (F12)") + self.setStyleSheet(""" + QPushButton { + background-color: #3c3c3c; + color: #80c7f7; + border: none; + font-size: 14px; + padding: 5px; + } + QPushButton:hover { + background-color: #4fc3f7; + color: white; + } + QPushButton:checked { + background-color: #4fc3f7; + color: white; + } + """) + self.setCheckable(True) + + +class LatexPanel(QWidget): + """Panel LaTeX con WebEngine o fallback a HTML""" def __init__(self, parent=None): super().__init__(parent) self.equations = [] - self.setup_webview() + self._webview_available = False + self._setup_ui() + + def _setup_ui(self): + """Configura la UI del panel""" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Header + header = QFrame() + header.setFixedHeight(30) + header.setStyleSheet("background-color: #1a1a1a; border-bottom: 1px solid #3c3c3c;") + header_layout = QHBoxLayout(header) + header_layout.setContentsMargins(10, 0, 10, 0) + + title = QLabel("📐 Ecuaciones LaTeX") + title.setStyleSheet("color: #80c7f7; font-weight: bold;") + header_layout.addWidget(title) + header_layout.addStretch() + + layout.addWidget(header) + + # Intentar crear WebEngineView + try: + self.webview = QWebEngineView() + self.webview.setContextMenuPolicy(Qt.NoContextMenu) + self._setup_webview() + layout.addWidget(self.webview) + self._webview_available = True + logging.debug("✅ WebEngineView disponible para LaTeX") + except Exception as e: + logging.warning(f"⚠️ WebEngineView no disponible: {e}") + # Fallback a QTextBrowser + self.text_browser = QTextBrowser() + self.text_browser.setOpenExternalLinks(False) + self._setup_text_browser() + layout.addWidget(self.text_browser) + self._webview_available = False - def setup_webview(self): - """Configura el WebView con MathJax""" - self.load_mathjax_base() + def _setup_webview(self): + """Configura WebEngineView con MathJax""" + html_content = self._generate_mathjax_html() + self.webview.setHtml(html_content) - def load_mathjax_base(self): - """Carga la página base con MathJax mejorado""" - html_content = self.generate_base_html() - self.setHtml(html_content) + def _setup_text_browser(self): + """Configura el browser de texto como fallback""" + self.text_browser.setStyleSheet(""" + QTextBrowser { + background-color: #1a1a1a; + color: #d4d4d4; + border: none; + font-family: 'Consolas'; + font-size: 11px; + padding: 10px; + } + """) + self.text_browser.setHtml(""" + +
+ 📐 Panel de Ecuaciones
+ Las ecuaciones aparecerán aquí +
+ """) - def generate_base_html(self): - """Genera HTML base con MathJax y parsing mejorado de LaTeX""" + def _generate_mathjax_html(self): + """Genera HTML base con MathJax""" return """ - Panel LaTeX
📐 Panel de Ecuaciones LaTeX
- Las ecuaciones, asignaciones y comentarios aparecerán aquí + Las ecuaciones aparecerán aquí automáticamente
@@ -295,118 +275,205 @@ class MathJaxPanel(QWebEngineView): function addEquation(type, content) { var container = document.getElementById('equations-container'); - // Limpiar mensaje de info si es la primera ecuación - if (container.children.length === 1 && container.querySelector('.info-message')) { - container.innerHTML = ''; - } + // Limpiar mensaje inicial si existe + var infoMsg = container.querySelector('.info-message'); + if (infoMsg) infoMsg.remove(); var equation = document.createElement('div'); equation.className = 'equation-block ' + type; - // Mejorar el processing de LaTeX - var processedContent = preprocessLatex(content); + var typeLabel = document.createElement('div'); + typeLabel.className = 'equation-type'; + typeLabel.textContent = type.charAt(0).toUpperCase() + type.slice(1); + var mathContent = document.createElement('div'); if (type === 'comment') { - equation.innerHTML = - '
Comentario
' + - '
' + escapeHtml(content) + '
'; + mathContent.style.fontStyle = 'italic'; + mathContent.style.color = '#6a9955'; + mathContent.textContent = content; } else { - equation.innerHTML = - '
' + getTypeLabel(type) + '
' + - '
$$' + processedContent + '$$
'; + mathContent.innerHTML = '$$' + content + '$$'; } + equation.appendChild(typeLabel); + equation.appendChild(mathContent); container.appendChild(equation); // Re-renderizar MathJax - if (window.MathJax && MathJax.typesetPromise) { + if (window.MathJax && type !== 'comment') { MathJax.typesetPromise([equation]).catch(function(err) { - console.error('Error de MathJax:', err); - // Fallback: mostrar como texto plano - if (type !== 'comment') { - equation.querySelector('.math-display').innerHTML = escapeHtml(content); - } + console.error('MathJax error:', err); }); } } - function preprocessLatex(latex) { - // Asegurar que las divisiones se manejen correctamente - latex = latex.replace(/\\/g, '\\\\'); // Escapar backslashes - - // Mejorar parsing de fracciones - latex = latex.replace(/(\w+)\s*\/\s*(\w+)/g, '\\\\frac{$1}{$2}'); - latex = latex.replace(/\(([^)]+)\)\s*\/\s*\(([^)]+)\)/g, '\\\\frac{$1}{$2}'); - - // Asegurar que sqrt funcione correctamente - latex = latex.replace(/sqrt\s*\(([^)]+)\)/g, '\\\\sqrt{$1}'); - - // Mejorar funciones trigonométricas - latex = latex.replace(/\b(sin|cos|tan|log|ln|exp)\b/g, '\\\\$1'); - - return latex; - } - - function getTypeLabel(type) { - const labels = { - 'assignment': 'Asignación', - 'equation': 'Ecuación', - 'symbolic': 'Simbólico', - 'comment': 'Comentario' - }; - return labels[type] || 'Resultado'; - } - - function escapeHtml(text) { - var div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - function clearEquations() { var container = document.getElementById('equations-container'); - container.innerHTML = '
📐 Panel de Ecuaciones LaTeX
Las ecuaciones, asignaciones y comentarios aparecerán aquí
'; + container.innerHTML = '
📐 Panel de Ecuaciones LaTeX
Las ecuaciones aparecerán aquí automáticamente
'; } """ - - def add_equation(self, equation_type: str, latex_content: str): - """Añade una ecuación al panel con mejor manejo de tipos""" - self.equations.append({'type': equation_type, 'content': latex_content}) + + def add_equation(self, eq_type: str, content: str): + """Añade una ecuación al panel""" + self.equations.append({'type': eq_type, 'content': content}) - if equation_type == "plot": - # Para plots, insertar HTML directo en lugar de LaTeX - js_code = f""" - var container = document.getElementById('equations-container'); - var plotDiv = document.createElement('div'); - plotDiv.innerHTML = `{latex_content}`; - plotDiv.className = 'plot-container'; - plotDiv.style.cursor = 'pointer'; - plotDiv.onclick = function() {{ - // Señalar que se hizo click en el plot - window.plotClicked = true; - }}; - container.appendChild(plotDiv); - """ + if self._webview_available: + # Escapar contenido para JavaScript + escaped_content = content.replace('\\', '\\\\').replace("'", "\\'").replace('"', '\\"') + js_code = f"addEquation('{eq_type}', '{escaped_content}');" + self.webview.page().runJavaScript(js_code) else: - # Para ecuaciones LaTeX normales - usar escapado más seguro - escaped_content = latex_content.replace('`', '\\`').replace('\\', '\\\\').replace("'", "\\'") - js_code = f"addEquation('{equation_type}', '{escaped_content}');" - - self.page().runJavaScript(js_code) + # Actualizar HTML en text browser + self._update_text_browser() def clear_equations(self): """Limpia todas las ecuaciones""" self.equations.clear() - self.page().runJavaScript("clearEquations();") + + if self._webview_available: + self.webview.page().runJavaScript("clearEquations();") + else: + self._setup_text_browser() # Reset al estado inicial + + def _update_text_browser(self): + """Actualiza el contenido del text browser (fallback)""" + html_parts = [""" + + """] + + for eq in self.equations: + eq_type = eq['type'] + content = eq['content'] + + type_label = eq_type.capitalize() + css_class = eq_type + + if eq_type == 'comment': + html_parts.append(f'
{type_label}
{content}
') + else: + # Para ecuaciones matemáticas, mostrar en formato de código + html_parts.append(f'
{type_label}
{content}
') + + self.text_browser.setHtml(''.join(html_parts)) + + +class AutocompletePopup(QWidget): + """Popup de autocompletado modeless""" + + item_selected = Signal(str) # Emite el texto seleccionado + + def __init__(self, parent=None): + super().__init__(parent, Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) + self.setAttribute(Qt.WA_ShowWithoutActivating, True) + self.setFocusPolicy(Qt.NoFocus) + + # Lista de sugerencias + self.listbox = QListWidget(self) + self.listbox.setFocusPolicy(Qt.NoFocus) + self.listbox.itemDoubleClicked.connect(self._on_item_double_clicked) + + # Layout + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.listbox) + + # Estilo + self.setStyleSheet(""" + QWidget { + background-color: #3c3f41; + border: 1px solid #4fc3f7; + border-radius: 4px; + } + QListWidget { + background-color: #3c3f41; + color: #bbbbbb; + border: none; + font-family: 'Consolas'; + font-size: 10px; + outline: none; + } + QListWidget::item { + padding: 3px 8px; + border: none; + } + QListWidget::item:selected { + background-color: #007acc; + color: white; + } + QListWidget::item:hover { + background-color: #094771; + color: #e0e0e0; + } + """) + + self._suggestions = [] + self._selected_index = 0 + + def set_suggestions(self, suggestions: List[Tuple[str, str]]): + """Establece las sugerencias [(nombre, descripción), ...]""" + self._suggestions = suggestions + self.listbox.clear() + + for name, desc in suggestions: + self.listbox.addItem(f"{name} — {desc}") + + if self.listbox.count() > 0: + self.listbox.setCurrentRow(0) + self._selected_index = 0 + + def navigate(self, direction: int): + """Navega por las sugerencias (direction: -1=arriba, 1=abajo)""" + if self.listbox.count() == 0: + return + + new_index = (self._selected_index + direction) % self.listbox.count() + self._selected_index = new_index + self.listbox.setCurrentRow(new_index) + + def get_selected_text(self) -> str: + """Obtiene el texto de la sugerencia seleccionada""" + if 0 <= self._selected_index < len(self._suggestions): + return self._suggestions[self._selected_index][0] + return "" + + def _on_item_double_clicked(self, item): + """Maneja doble click en un item""" + text = item.text().split(" —")[0].strip() + self.item_selected.emit(text) + + def adjust_size(self): + """Ajusta el tamaño del popup según el contenido""" + if self.listbox.count() == 0: + return + + # Calcular tamaño necesario + max_width = 300 + for i in range(self.listbox.count()): + item = self.listbox.item(i) + width = self.listbox.fontMetrics().horizontalAdvance(item.text()) + 20 + max_width = max(max_width, width) + + max_width = min(max_width, 600) + height = min(self.listbox.count() * 20 + 10, 200) + + self.setFixedSize(max_width, height) class HybridCalculatorPySide6(QMainWindow): - """Aplicación principal del CAS híbrido - Diseño minimalista de 3 paneles con persistencia""" + """Aplicación principal del CAS híbrido - VERSIÓN COMPLETA""" - SETTINGS_FILE = "hybrid_calc_settings_pyside6.json" - HISTORY_FILE = "hybrid_calc_history_pyside6.txt" + SETTINGS_FILE = "hybrid_calc_settings.json" + HISTORY_FILE = "hybrid_calc_history.txt" def __init__(self): super().__init__() @@ -415,97 +482,57 @@ class HybridCalculatorPySide6(QMainWindow): logging.basicConfig(level=logging.DEBUG, format='%(levelname)s: %(message)s') self.logger = logging.getLogger(__name__) - # ========== USAR EL MOTOR ORIGINAL SIN CAMBIOS ========== + # Motor y managers self.engine = PureAlgebraicEngine() self.interactive_manager = None - # Variables de configuración con persistencia - self.settings = self.load_settings() + # Configuración + self.settings = self._load_settings() self.debug = self.settings.get("debug_mode", False) - # Variables de estado UI - self.latex_panel_visible = self.settings.get("latex_panel_visible", True) - self._debounce_timer = QTimer() - self._debounce_timer.setSingleShot(True) - self._debounce_timer.timeout.connect(self._evaluate_and_update) - - # ========== VARIABLES DE AUTOCOMPLETADO COMPLETO (ADAPTADO DE TKINTER) ========== - # Popup principal de autocompletado + # ========== VARIABLES DE AUTOCOMPLETADO (COMPLETO COMO EN TKINTER) ========== self._autocomplete_popup = None - self._autocomplete_listbox = None self._autocomplete_active = False self._autocomplete_suggestions = [] self._autocomplete_filter_text = "" self._autocomplete_trigger_pos = None self._popup_disabled_until_next_dot = False - - # Popup de variables (timer-based) self._variable_popup_active = False - self._variable_popup_job = None - self._last_input_change = 0 self._last_navigation_time = 0 - - # Estado de filtrado y navegación + self._last_input_change = 0 self._current_suggestions = [] self._is_global_popup = False - self._selected_index = 0 - self._last_navigation_time = 0 - self._last_input_change = 0 - # Timers para autocompletado + # Timers + self._debounce_timer = QTimer() + self._debounce_timer.setSingleShot(True) + self._debounce_timer.timeout.connect(self._evaluate_and_update) + self._variable_popup_timer = QTimer() self._variable_popup_timer.setSingleShot(True) - self._variable_popup_timer.timeout.connect(self._show_variable_autocomplete) + self._variable_popup_timer.timeout.connect(self._show_variable_autocomplete_improved) - # ========== CONFIGURAR HELPERS DINÁMICOS (COMO EN ORIGINAL) ========== + # Estado del panel LaTeX + self.latex_panel_visible = self.settings.get("latex_panel_visible", True) + self._latex_equations = [] + + # Configurar helpers self._setup_dynamic_helpers() - # Configurar interfaz - self.setup_ui() - self.setup_shortcuts() - self.load_history() + # Configurar UI + self._setup_ui() + self._setup_menu() + self._setup_shortcuts() + self._setup_interactive_manager() - # Configurar manager interactivo - self.setup_interactive_manager() + # Cargar historial y configuración + self._load_history() + self._restore_geometry() - # Restaurar geometría guardada - self.restore_geometry() - - self.logger.info("✅ Calculadora MAV PySide6 inicializada") - - def restore_geometry(self): - """Restaura la geometría guardada de la ventana y splitter""" - try: - geometry_data = self.settings.get('window_geometry') - if geometry_data and isinstance(geometry_data, dict): - # Restaurar posición y tamaño de ventana - x = geometry_data.get('x', 100) - y = geometry_data.get('y', 100) - width = geometry_data.get('width', 1400) - height = geometry_data.get('height', 800) - self.setGeometry(x, y, width, height) - - # Restaurar tamaños de splitter después de mostrar la ventana - QTimer.singleShot(100, self.restore_splitter_sizes) - - except Exception as e: - self.logger.warning(f"No se pudo restaurar geometría: {e}") - self.setGeometry(100, 100, 1400, 800) # Valores por defecto - - def restore_splitter_sizes(self): - """Restaura los tamaños del splitter""" - try: - splitter_sizes = self.settings.get('splitter_sizes') - if splitter_sizes and isinstance(splitter_sizes, list): - splitter = self.centralWidget() - if isinstance(splitter, QSplitter): - splitter.setSizes(splitter_sizes) - self.logger.debug(f"Tamaños de splitter restaurados: {splitter_sizes}") - except Exception as e: - self.logger.warning(f"No se pudieron restaurar tamaños de splitter: {e}") + self.logger.info("✅ Calculadora MAV PySide6 (versión completa) inicializada") def _setup_dynamic_helpers(self): - """Configura helpers dinámicamente desde el registro de tipos - COMO EN ORIGINAL""" + """Configura helpers dinámicamente desde el registro de tipos""" try: self.HELPERS = get_registered_helper_functions() self.HELPERS.append(SympyHelper.Helper) @@ -514,495 +541,275 @@ class HybridCalculatorPySide6(QMainWindow): self.logger.error(f"Error cargando helpers dinámicos: {e}") self.HELPERS = [SympyHelper.Helper] - def setup_interactive_manager(self): - """Configura el manager de contenido interactivo - COMO EN ORIGINAL""" - try: - self.interactive_manager = InteractiveResultManager(self) - - # Conectar señal para mostrar plots en MathJax - self.interactive_manager.plot_requested.connect(self._show_plot_in_mathjax) - - # Almacenar referencias de plots por id para links clickeables - self._plot_objects = {} # id -> PlotResult - - except Exception as e: - self.logger.error(f"Error configurando interactive manager: {e}") - self.interactive_manager = None - - def setup_ui(self): - """Configura la interfaz de usuario - DISEÑO MINIMALISTA DE 3 PANELES""" + def _setup_ui(self): + """Configura la interfaz de usuario completa""" self.setWindowTitle("Calculadora MAV - CAS Híbrido") - self.setGeometry(100, 100, 1400, 800) - self.setStyleSheet(self.get_minimal_dark_theme()) + self.setGeometry(100, 100, 1000, 700) - # ========== SPLITTER PRINCIPAL CON 3 PANELES ========== - main_splitter = QSplitter(Qt.Horizontal) - self.setCentralWidget(main_splitter) + # Widget central + central_widget = QWidget() + self.setCentralWidget(central_widget) - # ========== PANEL 1: ENTRADA ========== - self.input_text = LineNumberedPlainTextEdit() - self.input_text.setFont(QFont("Consolas", 11)) + # Layout principal horizontal + main_layout = QHBoxLayout(central_widget) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Splitter principal para entrada y salida + self.main_splitter = QSplitter(Qt.Horizontal) + + # Panel de entrada + self.input_text = InputTextEdit(self) self.input_text.setPlaceholderText("Introduce expresiones matemáticas...") self.input_text.textChanged.connect(self._on_input_changed) - # Configurar eventos de teclado para autocompletado - self.input_text.keyPressEvent = self._handle_key_press + # Panel de salida + self.output_text = OutputTextEdit() + self.output_text.link_clicked.connect(self._handle_output_link_click) - # Configurar highlighter - self.highlighter = MathInputHighlighter(self.input_text.document()) + # Añadir al splitter + self.main_splitter.addWidget(self.input_text) + self.main_splitter.addWidget(self.output_text) - # ========== PANEL 2: SALIDA (SINCRONIZADO 1:1) ========== - self.output_text = SynchronizedTextEdit() - self.output_text.setFont(QFont("Consolas", 11)) - self.output_text.setReadOnly(True) + # Configurar tamaños iniciales + self.main_splitter.setSizes([450, 450]) - # Sincronizar scroll entre entrada y salida - self.input_text.verticalScrollBar().valueChanged.connect( - self.output_text.verticalScrollBar().setValue - ) - self.output_text.verticalScrollBar().valueChanged.connect( - self.input_text.verticalScrollBar().setValue - ) + # Sincronizar scroll + self._setup_scroll_sync() - # ========== PANEL 3: MATHJAX (COLAPSABLE) ========== - self.latex_panel = MathJaxPanel() + # Añadir splitter al layout + main_layout.addWidget(self.main_splitter) - # ========== AÑADIR PANELES AL SPLITTER ========== - main_splitter.addWidget(self.input_text) - main_splitter.addWidget(self.output_text) - main_splitter.addWidget(self.latex_panel) + # Botón expandible para LaTeX + self.latex_button = ExpandableLatexButton() + self.latex_button.clicked.connect(self._toggle_latex_panel) + main_layout.addWidget(self.latex_button) - # Configurar tamaños iniciales y política de redimensionado - main_splitter.setSizes([400, 400, 300]) # Ancho inicial de cada panel - main_splitter.setStretchFactor(0, 1) # Panel entrada: estirable - main_splitter.setStretchFactor(1, 1) # Panel salida: estirable - main_splitter.setStretchFactor(2, 0) # Panel LaTeX: tamaño fijo + # Panel LaTeX (inicialmente oculto) + self.latex_panel = LatexPanel() + self.latex_panel.setMinimumWidth(300) + self.latex_panel.setMaximumWidth(500) - # ========== CONFIGURAR TAGS DE SALIDA ========== - self.setup_output_tags() - - # ========== MENÚ Y BARRA DE ESTADO ========== - self.create_menu_bar() - self.create_status_bar() - - def setup_output_tags(self): - """Configura los tags de formato para la salida - COMO EN ORIGINAL""" - doc = self.output_text.document() - - # Crear formatos de texto - self.tag_formats = {} - - # Error - error_format = QTextCharFormat() - error_format.setForeground(QColor("#ff6b6b")) - self.tag_formats['error'] = error_format - - # Resultado simbólico - symbolic_format = QTextCharFormat() - symbolic_format.setForeground(QColor("#c3e88d")) - self.tag_formats['symbolic'] = symbolic_format - - # Resultado numérico - numeric_format = QTextCharFormat() - numeric_format.setForeground(QColor("#89ddff")) - self.tag_formats['numeric'] = numeric_format - - # Tipo personalizado - custom_format = QTextCharFormat() - custom_format.setForeground(QColor("#f78c6c")) - self.tag_formats['custom'] = custom_format - - # Comentarios - comment_format = QTextCharFormat() - comment_format.setForeground(QColor("#6a9955")) - comment_format.setFontItalic(True) - self.tag_formats['comment'] = comment_format - - def _on_input_changed(self): - """Maneja cambios en la entrada con debounce - COMO EN ORIGINAL""" - self._debounce_timer.start(300) # 300ms delay - - # Programar autocompletado de variables si no hay popup activo - if not self._autocomplete_active and not self._variable_popup_active: - self._variable_popup_timer.start(800) # 800ms delay para variables - - def _evaluate_and_update(self): - """Evalúa la entrada y actualiza la salida - USANDO MOTOR ORIGINAL""" - try: - # Obtener líneas de entrada - input_text = self.input_text.toPlainText() - lines = input_text.split('\n') - - # Evaluar usando el motor original - self._evaluate_lines(lines) - - except Exception as e: - self.logger.error(f"Error en evaluación: {e}") - self._show_error(str(e)) - - def _evaluate_lines(self, lines: List[str]): - """Evalúa líneas usando el motor original - SIN CAMBIOS EN LÓGICA""" - try: - # ========== LIMPIAR CONTEXTO DEL MOTOR (COMO EN ORIGINAL) ========== - self.engine.clear_context() - - # Limpiar panel LaTeX - self.latex_panel.clear_equations() - - # Limpiar salida - self.output_text.clear() - - # Evaluar cada línea - output_lines = [] - for i, line in enumerate(lines): - original_line = line - line = line.strip() - - if not line: - # Línea vacía - output_lines.append("") - elif line.startswith('#'): - # Comentario - mostrar en panel de resultados Y en LaTeX - comment_text = line[1:].strip() - if comment_text: - # Añadir al panel de resultados como comentario - output_lines.append(("comment", f"# {comment_text}")) - # Añadir al panel LaTeX - self.latex_panel.add_equation("comment", comment_text) - else: - # Comentario vacío - output_lines.append(("comment", "#")) - else: - try: - # Evaluar usando el motor original - result = self.engine.evaluate_line(line) - - # Procesar resultado - output_data = self._process_evaluation_result(result) - if output_data: - # Pasar toda la tupla de datos, no solo el texto - output_lines.append(output_data[0]) # Tomar toda la tupla del resultado - - # Añadir al panel LaTeX si es aplicable - self._add_to_latex_panel_if_applicable(result) - else: - output_lines.append("") - - except Exception as e: - error_msg = f"❌ {str(e)}" - output_lines.append(error_msg) - self.logger.error(f"Error evaluando línea '{line}': {e}") - - # Mostrar salida manteniendo correspondencia 1:1 - self._display_output_lines(output_lines) - - except Exception as e: - self.logger.error(f"Error en _evaluate_lines: {e}") - self._show_error(str(e)) - - def _process_evaluation_result(self, result: EvaluationResult) -> List[tuple]: - """Procesa resultado de evaluación - USANDO ESTRUCTURA ORIGINAL""" - if not result.success: - return [("error", f"❌ {result.error_message}")] - - output_data = [] - - # Verificar si el resultado es un PlotResult para crear link clickeable - if hasattr(result, 'actual_result_object') and isinstance(result.actual_result_object, PlotResult): - plot_obj = result.actual_result_object - - # Crear link clickeable para el plot - if self.interactive_manager: - link_info = self.interactive_manager.create_interactive_link(plot_obj) - if link_info: - link_id, display_text, result_object = link_info - - # Almacenar referencia al plot - self._plot_objects[link_id] = result_object - - # Crear formato clickeable - output_data.append(("clickeable", display_text, link_id, result_object)) - return output_data - - # El resultado principal está en result.output - if result.output: - # Determinar el tipo de formato basado en result_type - if result.result_type == "error": - output_data.append(("error", f"❌ {result.output}")) - elif result.result_type == "plot": - output_data.append(("plot", f"📈 {result.output}")) - elif result.result_type == "symbolic": - output_data.append(("symbolic", f"📊 {result.output}")) - elif result.result_type == "numeric": - output_data.append(("numeric", f"🔢 {result.output}")) - else: - # Por defecto, mostrar como simbólico - output_data.append(("symbolic", result.output)) - - # Si no hay resultados, mostrar algo - if not output_data: - output_data.append(("symbolic", "✓")) - - return output_data - - def _display_output_lines(self, output_lines: List[str]): - """Muestra líneas de salida manteniendo correspondencia 1:1""" - self.output_text.clear() - - for i, line_data in enumerate(output_lines): - if i > 0: - self.output_text.append("") # Nueva línea - - # Manejar diferentes tipos de datos de línea - if isinstance(line_data, tuple) and len(line_data) >= 4 and line_data[0] == "clickeable": - # Es un link clickeable (tipo, texto, link_id, objeto) - _, display_text, link_id, result_object = line_data - self._append_clickeable_link(display_text, link_id, result_object) - elif isinstance(line_data, tuple) and len(line_data) >= 2: - # Es un formato tradicional (tipo, texto) - format_type, text = line_data[0], line_data[1] - if format_type == "error": - self._append_formatted_text(text, self.tag_formats['error']) - elif format_type == "symbolic": - self._append_formatted_text(text, self.tag_formats['symbolic']) - elif format_type == "numeric": - self._append_formatted_text(text, self.tag_formats['numeric']) - elif format_type == "comment": - self._append_formatted_text(text, self.tag_formats['comment']) - else: - self._append_formatted_text(text, self.tag_formats.get('custom', None)) - else: - # Es un string simple - line = str(line_data) - # Determinar formato basado en contenido - if line.startswith("❌"): - self._append_formatted_text(line, self.tag_formats['error']) - elif line.startswith("📊"): - self._append_formatted_text(line, self.tag_formats['symbolic']) - elif line.startswith("🔢"): - self._append_formatted_text(line, self.tag_formats['numeric']) - else: - self._append_formatted_text(line, self.tag_formats.get('custom', None)) - - def _append_formatted_text(self, text: str, format_obj: QTextCharFormat = None): - """Añade texto formateado al panel de salida""" - cursor = self.output_text.textCursor() - cursor.movePosition(QTextCursor.End) - - if format_obj: - cursor.insertText(text, format_obj) + if self.latex_panel_visible: + main_layout.addWidget(self.latex_panel) + self.latex_button.setChecked(True) else: - cursor.insertText(text) - - def _append_clickeable_link(self, display_text: str, link_id: str, result_object: Any): - """Añade un link clickeable al panel de salida""" - cursor = self.output_text.textCursor() - cursor.movePosition(QTextCursor.End) + self.latex_panel.hide() - # Crear formato para link clickeable - link_format = QTextCharFormat() - link_format.setForeground(QColor("#4fc3f7")) - link_format.setUnderlineStyle(QTextCharFormat.SingleUnderline) - link_format.setFontUnderline(True) + # Configurar tags de salida + self._setup_output_tags() - # Insertar texto con formato de link - cursor.insertText(display_text, link_format) + # Barra de estado + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self._update_status("🔢 Calculadora MAV - Sistema Algebraico Puro") - # Almacenar información del link para manejo de clicks - # (Esto requiere conectar eventos de click en el widget) - if not hasattr(self, '_clickeable_links'): - self._clickeable_links = {} + # Aplicar tema oscuro + self._apply_dark_theme() + + def _setup_scroll_sync(self): + """Sincroniza el scroll entre entrada y salida""" + def sync_input_to_output(): + if hasattr(self, '_syncing'): + return + self._syncing = True + self.output_text.verticalScrollBar().setValue( + self.input_text.verticalScrollBar().value() + ) + self._syncing = False - # Guardar posición del link para detección de clicks - start_pos = cursor.position() - len(display_text) - end_pos = cursor.position() - self._clickeable_links[(start_pos, end_pos)] = (link_id, result_object) + def sync_output_to_input(): + if hasattr(self, '_syncing'): + return + self._syncing = True + self.input_text.verticalScrollBar().setValue( + self.output_text.verticalScrollBar().value() + ) + self._syncing = False - # Conectar evento de click si no está conectado - if not hasattr(self, '_output_click_connected'): - self.output_text.mousePressEvent = self._handle_output_click - self._output_click_connected = True + self.input_text.verticalScrollBar().valueChanged.connect(sync_input_to_output) + self.output_text.verticalScrollBar().valueChanged.connect(sync_output_to_input) - def _handle_output_click(self, event): - """Maneja clicks en el panel de salida para detectar links clickeables""" - # Llamar al método original primero - QTextEdit.mousePressEvent(self.output_text, event) + def _setup_output_tags(self): + """Configura los formatos de texto para la salida""" + self.output_formats = { + 'error': self._create_format("#f44747", bold=True), + 'comment': self._create_format("#6a9955", italic=True), + 'assignment': self._create_format("#dcdcaa"), + 'equation': self._create_format("#c586c0"), + 'symbolic': self._create_format("#9cdcfe"), + 'numeric': self._create_format("#b5cea8"), + 'boolean': self._create_format("#569cd6"), + 'string': self._create_format("#ce9178"), + 'custom_type': self._create_format("#4ec9b0"), + 'plot': self._create_format("#569cd6", underline=True), + 'type_indicator': self._create_format("#808080"), + 'clickable': self._create_format("#4fc3f7", underline=True) + } + + def _create_format(self, color: str, bold: bool = False, italic: bool = False, underline: bool = False) -> QTextCharFormat: + """Crea un formato de texto""" + fmt = QTextCharFormat() + fmt.setForeground(QColor(color)) + if bold: + fmt.setFontWeight(QFont.Bold) + if italic: + fmt.setFontItalic(True) + if underline: + fmt.setFontUnderline(True) + return fmt + + def _setup_menu(self): + """Configura el menú completo""" + menubar = self.menuBar() - if hasattr(self, '_clickeable_links'): - # Obtener posición del click - cursor = self.output_text.cursorForPosition(event.pos()) - click_pos = cursor.position() - - # Buscar si el click fue en un link - for (start_pos, end_pos), (link_id, result_object) in self._clickeable_links.items(): - if start_pos <= click_pos <= end_pos: - # Click en un link - manejar según el tipo - if self.interactive_manager: - self.interactive_manager.handle_interactive_click(result_object) - break - - def _show_plot_in_mathjax(self, plot_result: PlotResult): - """Muestra un plot en el panel MathJax""" - try: - # Crear representación visual del plot para MathJax - # Por ahora, mostrar información del plot - plot_info = f""" -
-

📊 {plot_result.plot_type.title()}

-

Expresión: {plot_result.original_expression}

-

- 🔗 Click para editar y visualizar -

-
- """ - - # Agregar al panel MathJax como "ecuación" especial - self.latex_panel.add_equation("plot", plot_info) - - # Almacenar referencia para clicks posteriores en MathJax - if not hasattr(self, '_mathjax_plots'): - self._mathjax_plots = {} - self._mathjax_plots[id(plot_result)] = plot_result - - except Exception as e: - self.logger.error(f"Error mostrando plot en MathJax: {e}") - - def update_input_from_plot_edit(self, original_expr: str, new_expr: str): - """Actualiza el panel de entrada cuando se edita una expresión de plot""" - try: - # Obtener contenido actual del input - current_text = self.input_text.toPlainText() - - # Reemplazar la expresión original con la nueva - if original_expr in current_text: - updated_text = current_text.replace(original_expr, new_expr) - self.input_text.setPlainText(updated_text) - - # Re-evaluar automáticamente - self._evaluate_and_update() - - except Exception as e: - self.logger.error(f"Error actualizando input desde plot: {e}") - - def _add_to_latex_panel_if_applicable(self, result: EvaluationResult): - """Añade resultado al panel LaTeX - MEJORADO PARA COMENTARIOS Y ECUACIONES""" - if not result.success: - return + # Menú Archivo + file_menu = menubar.addMenu("Archivo") - try: - # Determinar si debe ir al panel LaTeX - REGLAS SIMPLIFICADAS - should_add_to_latex = False - equation_type = "symbolic" # Tipo por defecto - - # 1. SIEMPRE agregar comentarios (manejado en _evaluate_lines) - if result.result_type == "comment": - should_add_to_latex = True - equation_type = "comment" - - # 2. SIEMPRE agregar asignaciones - elif result.is_assignment: - should_add_to_latex = True - equation_type = "assignment" - - # 3. SIEMPRE agregar ecuaciones - elif result.is_equation: - should_add_to_latex = True - equation_type = "equation" - - # 4. Agregar CUALQUIER resultado exitoso que tenga contenido simbólico - elif result.success and result.output: - # Si tiene símbolos matemáticos o contenido algebraico, agregarlo - math_indicators = ['=', '+', '-', '*', '/', '^', 'sqrt', 'sin', 'cos', 'tan', 'log', 'exp'] - if any(indicator in result.output for indicator in math_indicators): - should_add_to_latex = True - equation_type = "symbolic" - - # También agregar si es claramente una expresión matemática - elif (result.actual_result_object is not None and - hasattr(result.actual_result_object, '__class__')): - try: - if isinstance(result.actual_result_object, sympy.Basic): - should_add_to_latex = True - equation_type = "symbolic" - except: - pass - - if should_add_to_latex: - # Preparar contenido LaTeX - latex_content = "" - - if result.actual_result_object is not None: - try: - # Intentar convertir a LaTeX usando SymPy - latex_content = self._sympy_to_latex(result.actual_result_object) - except Exception as e: - # Fallback al output de texto - latex_content = result.output if result.output else str(result.actual_result_object) - else: - latex_content = result.output if result.output else "" - - if latex_content: - self.latex_panel.add_equation(equation_type, latex_content) + new_action = QAction("Nuevo", self) + new_action.setShortcut(QKeySequence.New) + new_action.triggered.connect(self.new_session) + file_menu.addAction(new_action) - except Exception as e: - self.logger.error(f"Error añadiendo al panel LaTeX: {e}") + file_menu.addSeparator() + + load_action = QAction("Cargar...", self) + load_action.setShortcut(QKeySequence.Open) + load_action.triggered.connect(self.load_file) + file_menu.addAction(load_action) + + save_action = QAction("Guardar como...", self) + save_action.setShortcut(QKeySequence.Save) + save_action.triggered.connect(self.save_file) + file_menu.addAction(save_action) + + file_menu.addSeparator() + + exit_action = QAction("Salir", self) + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + # Menú Editar + edit_menu = menubar.addMenu("Editar") + + clear_input_action = QAction("Limpiar entrada", self) + clear_input_action.triggered.connect(self.clear_input) + edit_menu.addAction(clear_input_action) + + clear_output_action = QAction("Limpiar salida", self) + clear_output_action.triggered.connect(self.clear_output) + edit_menu.addAction(clear_output_action) + + edit_menu.addSeparator() + + clear_history_action = QAction("Limpiar historial", self) + clear_history_action.triggered.connect(self.clear_history) + edit_menu.addAction(clear_history_action) + + # Menú Ver + view_menu = menubar.addMenu("Ver") + + toggle_latex_action = QAction("📐 Panel LaTeX", self) + toggle_latex_action.setShortcut(QKeySequence("F12")) + toggle_latex_action.triggered.connect(self._toggle_latex_panel) + view_menu.addAction(toggle_latex_action) + + view_menu.addSeparator() + + system_info_action = QAction("Información del sistema", self) + system_info_action.triggered.connect(self.show_types_info) + view_menu.addAction(system_info_action) + + # Menú Herramientas + tools_menu = menubar.addMenu("Herramientas") + + reload_types_action = QAction("Recargar Tipos Personalizados", self) + reload_types_action.triggered.connect(self.reload_types) + tools_menu.addAction(reload_types_action) + + tools_menu.addSeparator() + + # Menú de diagnóstico + diag_menu = tools_menu.addMenu("Diagnóstico") + + mathjax_diag_action = QAction("🔍 Diagnóstico MathJax", self) + mathjax_diag_action.triggered.connect(self._diagnose_mathjax) + diag_menu.addAction(mathjax_diag_action) + + latex_status_action = QAction("📊 Estado Panel LaTeX", self) + latex_status_action.triggered.connect(self._show_latex_panel_status) + diag_menu.addAction(latex_status_action) + + # Menú Tipos + types_menu = menubar.addMenu("Tipos") + + types_info_action = QAction("Información de tipos", self) + types_info_action.triggered.connect(self.show_types_info) + types_menu.addAction(types_info_action) + + types_menu.addSeparator() + + types_syntax_action = QAction("Sintaxis de tipos", self) + types_syntax_action.triggered.connect(self.show_types_syntax) + types_menu.addAction(types_syntax_action) + + # Menú Ayuda + help_menu = menubar.addMenu("Ayuda") + + quick_guide_action = QAction("Guía rápida", self) + quick_guide_action.triggered.connect(self.show_quick_guide) + help_menu.addAction(quick_guide_action) + + syntax_help_action = QAction("Sintaxis", self) + syntax_help_action.triggered.connect(self.show_syntax_help) + help_menu.addAction(syntax_help_action) + + sympy_funcs_action = QAction("Funciones SymPy", self) + sympy_funcs_action.triggered.connect(self.show_sympy_functions) + help_menu.addAction(sympy_funcs_action) + + help_menu.addSeparator() + + about_action = QAction("Acerca de", self) + about_action.triggered.connect(self.show_about) + help_menu.addAction(about_action) - def _sympy_to_latex(self, sympy_obj) -> str: - """Convierte objeto SymPy a LaTeX con mejor manejo de divisiones y funciones""" - try: - if sympy_obj is None: - return "" - - # Usar la función latex de SymPy - latex_str = sympy.latex(sympy_obj) - - # Mejorar el LaTeX para mejor renderizado - # Asegurar que las fracciones se manejen correctamente - latex_str = latex_str.replace('\\frac', '\\frac') # Verificar escape correcto - - # Asegurar que sqrt funcione correctamente - latex_str = latex_str.replace('\\sqrt', '\\sqrt') - - # NO envolver en delimitadores aquí - se hace en el JavaScript - return latex_str - - except Exception as e: - self.logger.debug(f"Error convirtiendo a LaTeX: {e}") - # Fallback: intentar conversión simple - try: - result_str = str(sympy_obj) - # Convertir notación Python a LaTeX básico - result_str = result_str.replace('**', '^') - result_str = result_str.replace('sqrt(', '\\sqrt{').replace(')', '}') - return result_str - except: - return str(sympy_obj) + def _setup_shortcuts(self): + """Configura atajos de teclado""" + # Evaluación manual + eval_shortcut = QShortcut(QKeySequence("Ctrl+Return"), self) + eval_shortcut.activated.connect(self._evaluate_and_update) + + # Toggle LaTeX panel + latex_shortcut = QShortcut(QKeySequence("F12"), self) + latex_shortcut.activated.connect(self._toggle_latex_panel) + + # Nuevo + new_shortcut = QShortcut(QKeySequence.New, self) + new_shortcut.activated.connect(self.new_session) - def _show_error(self, error_msg: str): - """Muestra mensaje de error""" - self.output_text.append(f"❌ Error: {error_msg}") - self.statusBar().showMessage(f"❌ {error_msg}", 5000) + def _setup_interactive_manager(self): + """Configura el gestor de resultados interactivos""" + self.interactive_manager = InteractiveResultManager(self) + self.interactive_manager.set_update_callback(self._update_input_expression) - def get_minimal_dark_theme(self): - """Tema oscuro minimalista""" - return """ + def _apply_dark_theme(self): + """Aplica tema oscuro a la aplicación""" + dark_theme = """ QMainWindow { - background-color: #1e1e1e; + background-color: #2b2b2b; color: #d4d4d4; } QPlainTextEdit, QTextEdit { - background-color: #252526; + background-color: #1e1e1e; color: #d4d4d4; border: 1px solid #3c3c3c; selection-background-color: #264f78; - padding: 8px; } QMenuBar { background-color: #2d2d30; color: #d4d4d4; border-bottom: 1px solid #3c3c3c; - padding: 2px; } QMenuBar::item:selected { background-color: #094771; @@ -1016,280 +823,508 @@ class HybridCalculatorPySide6(QMainWindow): background-color: #094771; } QStatusBar { - background-color: #007acc; - color: white; - border: none; + background-color: #2b2b2b; + color: #80c7f7; + border-top: 1px solid #3c3c3c; } QSplitter::handle { - background-color: #4fc3f7; - border-radius: 1px; + background-color: #3c3c3c; + width: 4px; } - QSplitter::handle:horizontal { - width: 3px; - margin: 2px 0; + QSplitter::handle:hover { + background-color: #007acc; } - QSplitter::handle:horizontal:hover { - background-color: #82aaff; + QScrollBar:vertical { + background-color: #1e1e1e; + width: 12px; + border: none; + } + QScrollBar::handle:vertical { + background-color: #3c3c3c; + min-height: 20px; + border-radius: 6px; + } + QScrollBar::handle:vertical:hover { + background-color: #4c4c4c; } """ + self.setStyleSheet(dark_theme) - def setup_shortcuts(self): - """Configura atajos de teclado""" - # Evaluar manual (forzar evaluación inmediata) - eval_shortcut = QShortcut(QKeySequence("Ctrl+Return"), self) - eval_shortcut.activated.connect(self._evaluate_and_update) - - eval_shortcut2 = QShortcut(QKeySequence("Shift+Return"), self) - eval_shortcut2.activated.connect(self._evaluate_and_update) - - # Toggle LaTeX panel - latex_shortcut = QShortcut(QKeySequence("F12"), self) - latex_shortcut.activated.connect(self.toggle_latex_panel) + # ========== SISTEMA DE EVALUACIÓN ========== - def create_menu_bar(self): - """Crea la barra de menú minimalista""" - menubar = self.menuBar() + def _on_input_changed(self): + """Maneja cambios en la entrada con debounce""" + self._debounce_timer.stop() + self._debounce_timer.start(300) - # Menú Archivo - file_menu = menubar.addMenu("Archivo") + # Cancelar popup de variables si existe + self._variable_popup_timer.stop() - new_action = file_menu.addAction("Nuevo") - new_action.setShortcut(QKeySequence.New) - new_action.triggered.connect(self.new_session) - - open_action = file_menu.addAction("Abrir...") - open_action.setShortcut(QKeySequence.Open) - open_action.triggered.connect(self.load_file) - - save_action = file_menu.addAction("Guardar...") - save_action.setShortcut(QKeySequence.Save) - save_action.triggered.connect(self.save_file) - - file_menu.addSeparator() - exit_action = file_menu.addAction("Salir") - exit_action.triggered.connect(self.close) - - # Menú Ver - view_menu = menubar.addMenu("Ver") - toggle_latex_action = view_menu.addAction("Mostrar/Ocultar LaTeX") - toggle_latex_action.setShortcut(QKeySequence("F12")) - toggle_latex_action.triggered.connect(self.toggle_latex_panel) - - # Menú Ayuda - help_menu = menubar.addMenu("Ayuda") - about_action = help_menu.addAction("Acerca de...") - about_action.triggered.connect(self.show_about) + # Programar autocompletado de variables + if not self._autocomplete_active and not self._popup_disabled_until_next_dot: + self._variable_popup_timer.start(800) - def create_status_bar(self): - """Crea la barra de estado""" - self.statusBar().showMessage("🔢 Calculadora MAV - Sistema Algebraico Híbrido") - - def toggle_latex_panel(self): - """Muestra/oculta el panel LaTeX""" - self.latex_panel_visible = not self.latex_panel_visible - - if self.latex_panel_visible: - self.latex_panel.show() - else: - self.latex_panel.hide() - - def new_session(self): - """Inicia una nueva sesión""" - self.input_text.clear() - self.output_text.clear() - self.latex_panel.clear_equations() - self.statusBar().showMessage("✨ Nueva sesión iniciada", 2000) - - def load_file(self): - """Carga un archivo""" - file_path, _ = QFileDialog.getOpenFileName( - self, "Abrir archivo", "", "Archivos de texto (*.txt);;Todos los archivos (*)" - ) - if file_path: - try: - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - self.input_text.setPlainText(content) - self.statusBar().showMessage(f"📁 Archivo cargado: {Path(file_path).name}", 3000) - except Exception as e: - QMessageBox.warning(self, "Error", f"No se pudo cargar el archivo:\n{e}") - - def save_file(self): - """Guarda el contenido actual""" - file_path, _ = QFileDialog.getSaveFileName( - self, "Guardar archivo", "", "Archivos de texto (*.txt);;Todos los archivos (*)" - ) - if file_path: - try: - with open(file_path, 'w', encoding='utf-8') as f: - f.write(self.input_text.toPlainText()) - self.statusBar().showMessage(f"💾 Archivo guardado: {Path(file_path).name}", 3000) - except Exception as e: - QMessageBox.warning(self, "Error", f"No se pudo guardar el archivo:\n{e}") - - def show_about(self): - """Muestra información sobre la aplicación""" - QMessageBox.about( - self, - "Acerca de Calculadora MAV", - """ -

Calculadora MAV - CAS Híbrido

-

Versión PySide6 con diseño minimalista

-

Sistema algebraico computacional híbrido con 3 paneles:

- -

Motor algebraico: SymPy con tipos personalizados

- """ - ) - - def load_settings(self) -> Dict[str, Any]: - """Carga configuración desde archivo""" + def _evaluate_and_update(self): + """Evalúa todas las líneas y actualiza la salida""" try: - if Path(self.SETTINGS_FILE).exists(): - with open(self.SETTINGS_FILE, 'r', encoding='utf-8') as f: - return json.load(f) + input_content = self.input_text.toPlainText() + if not input_content.strip(): + self._clear_output() + return + + # Limpiar contexto del motor + self.engine.equations.clear() + self.engine.symbol_table.clear() + self.engine.variables.clear() + self.logger.debug("Contexto del motor limpiado") + + # Limpiar panel LaTeX + if hasattr(self, '_latex_equations'): + self._latex_equations.clear() + self.latex_panel.clear_equations() + + lines = input_content.splitlines() + self._evaluate_lines(lines) + except Exception as e: - self.logger.warning(f"No se pudo cargar configuración: {e}") - - return { - "window_geometry": "1400x800", - "debug_mode": False, - "latex_panel_visible": True - } + self._show_error(f"Error durante evaluación: {e}") - def save_settings(self): - """Guarda configuración a archivo con geometría completa""" + 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_stripped = line.strip() + + # Líneas vacías o comentarios + if not line_stripped or line_stripped.startswith('#'): + if line_stripped: + output_data.append([("comment", line_stripped)]) + # Añadir comentario al panel LaTeX + if line_stripped.startswith('#'): + comment_text = line_stripped[1:].strip() + self._add_to_latex_panel("comment", comment_text) + else: + output_data.append([("", "")]) + continue + + # Evaluar línea + result = self.engine.evaluate_line(line_stripped) + 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""" + output_parts = [] + indicator_text: Optional[str] = None + + # Añadir al panel LaTeX si es aplicable + self._add_to_latex_panel_if_applicable(result) + + 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 con ayuda contextual + error_msg = f"Error: {result.error_message}" + + # Intentar obtener ayuda + ayuda_text = self._obtener_ayuda(result.input_line) + if ayuda_text: + ayuda_linea = ayuda_text.replace("\n", " ").strip() + if len(ayuda_linea) > 120: + ayuda_linea = ayuda_linea[:117] + "..." + + output_parts.append(("error", error_msg)) + output_parts.append(("\n", "\n")) + output_parts.append(("helper", f"Sugerencia: {ayuda_linea}")) + else: + output_parts.append(("error", error_msg)) + + else: + # Intentar crear tag interactivo + if self.interactive_manager: + interactive_info = self.interactive_manager.create_interactive_link( + result.actual_result_object + ) + + if interactive_info: + link_id, display_text, result_object = interactive_info + output_parts.append(("clickable", display_text, link_id, result_object)) + + # Añadir indicador de tipo algebraico + if result.algebraic_type: + indicator_text = f"[{result.algebraic_type}]" + output_parts.append((" ", " ")) + output_parts.append(("type_indicator", indicator_text)) + return output_parts + + # Si no es interactivo, usar formato normal + 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" + else: + # Determinar tag según tipo algebraico + if result.algebraic_type: + type_lower = result.algebraic_type.lower() + if isinstance(result.actual_result_object, sympy.Basic): + main_output_tag = "symbolic" + elif type_lower in ["int", "float", "complex"]: + main_output_tag = "numeric" + elif type_lower == "bool": + main_output_tag = "boolean" + elif type_lower == "str": + main_output_tag = "string" + else: + main_output_tag = "custom_type" + + if result.algebraic_type: + is_collection = any(kw in result.algebraic_type.lower() + for kw in ["matrix", "list", "dict", "tuple", "vector", "array"]) + if is_collection or isinstance(result.actual_result_object, sympy.Basic): + 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 _add_to_latex_panel_if_applicable(self, result: EvaluationResult): + """Agrega resultado al panel LaTeX si es aplicable""" try: - # Actualizar configuraciones de UI - self.settings['latex_panel_visible'] = self.latex_panel_visible - self.settings['debug_mode'] = self.debug + should_add_to_latex = False + equation_type = "comment" - # Guardar geometría de ventana - geometry = self.geometry() - self.settings['window_geometry'] = { - 'x': geometry.x(), - 'y': geometry.y(), - 'width': geometry.width(), - 'height': geometry.height() - } + if result.result_type == "comment": + should_add_to_latex = True + equation_type = "comment" + elif result.is_assignment: + should_add_to_latex = True + equation_type = "assignment" + elif result.is_equation: + should_add_to_latex = True + equation_type = "equation" + elif result.success and result.output: + # Agregar si tiene contenido matemático + math_indicators = ['=', '+', '-', '*', '/', '^', 'sqrt', 'sin', 'cos', 'tan', 'log', 'exp'] + if any(indicator in result.output for indicator in math_indicators): + should_add_to_latex = True + equation_type = "symbolic" + elif result.actual_result_object is not None and isinstance(result.actual_result_object, sympy.Basic): + should_add_to_latex = True + equation_type = "symbolic" - # Guardar tamaños de splitter - if hasattr(self, 'centralWidget') and isinstance(self.centralWidget(), QSplitter): - splitter_sizes = self.centralWidget().sizes() - self.settings['splitter_sizes'] = splitter_sizes - - with open(self.SETTINGS_FILE, 'w', encoding='utf-8') as f: - json.dump(self.settings, f, indent=2, ensure_ascii=False) + if should_add_to_latex: + latex_content = "" + + if result.actual_result_object is not None: + try: + latex_content = sympy.latex(result.actual_result_object) + except: + latex_content = result.output if result.output else str(result.actual_result_object) + else: + latex_content = result.output if result.output else "" + + self._add_to_latex_panel(equation_type, latex_content) except Exception as e: - self.logger.error(f"Error guardando configuración: {e}") + self.logger.error(f"Error procesando para panel LaTeX: {e}") - def load_history(self): - """Carga historial desde archivo""" + def _add_to_latex_panel(self, equation_type: str, latex_content: str): + """Añade una ecuación al panel LaTeX""" + if not hasattr(self, '_latex_equations'): + self._latex_equations = [] + + self._latex_equations.append({ + 'type': equation_type, + 'content': latex_content + }) + + self.latex_panel.add_equation(equation_type, latex_content) + + # Actualizar indicador visual + self._update_latex_indicator() + + def _update_latex_indicator(self): + """Actualiza el indicador visual de contenido LaTeX""" + equation_count = len(self._latex_equations) if hasattr(self, '_latex_equations') else 0 + + if equation_count > 0: + self.latex_button.setToolTip(f"📐 Panel LaTeX ({equation_count} ecuaciones)") + else: + self.latex_button.setToolTip("📐 Panel LaTeX (sin ecuaciones)") + + def _display_output(self, output_data: List[List[tuple]]): + """Muestra los datos de salida en el widget""" + self.output_text.clear() + self.output_text.clickable_links.clear() + + cursor = self.output_text.textCursor() + + for line_idx, line_parts in enumerate(output_data): + if line_idx > 0: + cursor.insertText("\n") + + if not line_parts or (len(line_parts) == 1 and line_parts[0][0] == "" and line_parts[0][1] == ""): + continue + + for part_idx, part_data in enumerate(line_parts): + if len(part_data) >= 4 and part_data[0] == "clickable": + # Link clickeable + _, display_text, link_id, result_object = part_data + start_pos = cursor.position() + cursor.insertText(display_text, self.output_formats.get('clickable')) + end_pos = cursor.position() + self.output_text.clickable_links[(start_pos, end_pos)] = (link_id, result_object) + + elif len(part_data) >= 2: + tag, content = part_data[0], part_data[1] + if not content: + continue + + if part_idx > 0: + prev_tag = line_parts[part_idx-1][0] if part_idx > 0 else None + if tag not in ["type_indicator"] and prev_tag: + cursor.insertText(" ; ") + elif tag == "type_indicator" and prev_tag: + cursor.insertText(" ") + + format_obj = self.output_formats.get(tag, None) + if format_obj: + cursor.insertText(str(content), format_obj) + else: + cursor.insertText(str(content)) + + def _clear_output(self): + """Limpia el panel de salida""" + self.output_text.clear() + + def _show_error(self, error_msg: str): + """Muestra un error en el panel de salida""" + self.output_text.clear() + cursor = self.output_text.textCursor() + cursor.insertText(error_msg, self.output_formats['error']) + + def _handle_output_link_click(self, link_id: str, result_object): + """Maneja clicks en links del panel de salida""" + if self.interactive_manager: + self.interactive_manager.handle_interactive_click(result_object) + + def _update_input_expression(self, original_expression: str, new_expression: str): + """Actualiza el panel de entrada reemplazando la expresión original""" try: - if Path(self.HISTORY_FILE).exists(): - with open(self.HISTORY_FILE, 'r', encoding='utf-8') as f: - history = f.read().strip() - if history: - self.input_text.setPlainText(history) + current_content = self.input_text.toPlainText() + + if original_expression in current_content: + updated_content = current_content.replace(original_expression, new_expression, 1) + self.input_text.setPlainText(updated_content) + self._evaluate_and_update() + self.logger.info(f"Expresión actualizada: '{original_expression}' -> '{new_expression}'") + else: + # Si no se encuentra, agregar al final + if current_content and not current_content.endswith('\n'): + current_content += '\n' + updated_content = current_content + new_expression + self.input_text.setPlainText(updated_content) + except Exception as e: - self.logger.warning(f"No se pudo cargar historial: {e}") + self.logger.error(f"Error actualizando expresión: {e}") - def save_history(self): - """Guarda historial a archivo""" - try: - with open(self.HISTORY_FILE, 'w', encoding='utf-8') as f: - f.write(self.input_text.toPlainText()) - except Exception as e: - self.logger.error(f"Error guardando historial: {e}") + # ========== SISTEMA DE AUTOCOMPLETADO COMPLETO ========== - def closeEvent(self, event): - """Maneja el evento de cierre de la aplicación""" - self.save_settings() - self.save_history() - event.accept() - - # ========== SISTEMA DE AUTOCOMPLETADO ========== - - def _handle_key_press(self, event): - """Maneja eventos de teclado para autocompletado - SISTEMA COMPLETO DE TKINTER""" - # Procesar navegación en popup ANTES de insertar el carácter - if (self._autocomplete_active or self._variable_popup_active): + def _handle_key_press(self, event) -> bool: + """Maneja eventos de teclado para autocompletado - retorna True si manejó el evento""" + # Navegación en popup + if self._autocomplete_active or self._variable_popup_active: if event.key() == Qt.Key_Up: self._handle_arrow_key(-1) - event.accept() - return + return True elif event.key() == Qt.Key_Down: self._handle_arrow_key(1) - event.accept() - return + return True elif event.key() == Qt.Key_Tab: self._handle_tab_key() - event.accept() - return + return True elif event.key() == Qt.Key_Escape: self._handle_escape_key() - event.accept() - return - elif event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter: - # Cerrar popups cuando se presiona Enter - self._close_autocomplete_popup() - # Continuar con el evento normal de Enter - QPlainTextEdit.keyPressEvent(self.input_text, event) - return + return True + elif event.key() in [Qt.Key_Return, Qt.Key_Enter]: + self._select_autocomplete() + return True - # Detectar backspace para cerrar popup de funciones si se borra el punto + # Detectar backspace para cerrar popup si se borra el punto if event.key() == Qt.Key_Backspace and self._autocomplete_active: - QPlainTextEdit.keyPressEvent(self.input_text, event) QTimer.singleShot(1, self._check_dot_removal) - return - # Llamar al método original para insertar el carácter - QPlainTextEdit.keyPressEvent(self.input_text, event) + # Procesar autocompletado después de insertar carácter + if event.text() and not event.modifiers() & Qt.ControlModifier: + # Guardar datos del evento para evitar que se elimine el objeto + event_text = event.text() + event_key = event.key() + QTimer.singleShot(10, lambda: self._on_key_release_deferred(event_text, event_key)) - # Procesar autocompletado DESPUÉS de insertar el carácter - self._on_key_release(event) + return False - def _on_key_release(self, event): - """Maneja eventos después de insertar carácter - SISTEMA COMPLETO DE TKINTER""" - # Cancelar timer de variables si existe - if hasattr(self, '_variable_popup_job') and self._variable_popup_job: - self._variable_popup_timer.stop() - self._variable_popup_job = None + def _on_key_release_deferred(self, event_text: str, event_key: int): + """Maneja eventos después de insertar carácter usando datos guardados""" + # Cancelar timer de variables + self._variable_popup_timer.stop() - # Verificar si acabamos de navegar (evitar filtrado inmediato) - import time + # Verificar si acabamos de navegar just_navigated = (time.time() - self._last_navigation_time) < 0.1 # Manejar autocompletado con punto - if event.text() == '.' and not self._popup_disabled_until_next_dot: - # Cerrar popup de variables si está activo + if event_text == '.' and not self._popup_disabled_until_next_dot: if self._variable_popup_active: self._close_autocomplete_popup() - self._handle_dot_autocomplete() - # Filtrar autocompletado si está activo (pero no si acabamos de navegar) - elif self._autocomplete_active and event.text() and event.text().isprintable() and not just_navigated: + # Filtrar autocompletado si está activo + elif self._autocomplete_active and event_text and not just_navigated: self._filter_autocomplete() - # Marcar tiempo del último cambio de input - if event.text() and event.text().isprintable(): + # Marcar tiempo del último cambio + if event_text: self._last_input_change = time.time() - # Programar autocompletado de variables (nuevo sistema) + # Programar autocompletado de variables if not self._autocomplete_active and not self._popup_disabled_until_next_dot: - self._schedule_variable_autocomplete() + self._schedule_variable_autocomplete_improved() - def _schedule_variable_autocomplete(self): - """Programa el autocompletado de variables mientras se escribe""" + def _on_key_release(self, event): + """Maneja eventos después de insertar carácter - mantenido para compatibilidad""" + return self._on_key_release_deferred(event.text(), event.key()) + + def _handle_dot_autocomplete(self): + """Maneja el autocompletado cuando se escribe un punto""" + self._close_autocomplete_popup() + + cursor = self.input_text.textCursor() + cursor_pos = cursor.position() + + # Obtener línea actual + cursor.select(QTextCursor.LineUnderCursor) + line_text = cursor.selectedText() + + # Calcular posición del punto en la línea + line_start = cursor.selectionStart() + dot_pos_in_line = cursor_pos - line_start - 1 + + if dot_pos_in_line < 0: + return + + # Guardar posición del trigger + self._autocomplete_trigger_pos = cursor_pos + self._autocomplete_filter_text = "" + + # Texto antes del punto + text_before_dot = line_text[:dot_pos_in_line].strip() + + # Determinar si es popup GLOBAL + if not text_before_dot: + self.logger.debug("Dot en línea vacía. Ofreciendo sugerencias globales.") + suggestions = self._get_global_suggestions() + if suggestions: + self._show_autocomplete_popup(suggestions, is_global_popup=True) + return + + # Es popup de OBJETO + obj_expr_str = self._extract_object_expression(text_before_dot) + if not obj_expr_str: + return + + self.logger.debug(f"Autocompletado para objeto: '{obj_expr_str}'") + + # Caso especial para sympy + if obj_expr_str == "sympy": + methods = SympyHelper.PopupFunctionList() + if methods: + self._show_autocomplete_popup(methods, is_global_popup=False) + return + + # Preprocesar con bracket parser si es necesario + if '[' in obj_expr_str: + obj_expr_str = self.engine.parser._transform_brackets(obj_expr_str) + + # Evaluar la expresión del objeto + eval_context = self.engine._get_full_context() + try: + obj = eval(obj_expr_str, eval_context) + self.logger.debug(f"Objeto evaluado: {type(obj)}") + + # Mostrar métodos del objeto + if hasattr(obj, 'PopupFunctionList'): + methods = obj.PopupFunctionList() + if methods: + self._show_autocomplete_popup(methods, is_global_popup=False) + + except Exception as e: + self.logger.debug(f"Error evaluando objeto '{obj_expr_str}': {e}") + + def _extract_object_expression(self, text: str) -> str: + """Extrae la expresión del objeto del texto antes del punto""" + 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, text) + + if match: + return match.group(1).replace(" ", "") + + # Fallback + if re.match(r"^[a-zA-Z_0-9\(\)\[\]\.\"\'\+\-\*\/ ]*$", text) and \ + not text.endswith(("+", "-", "*", "/", "(", ",")): + return text + + return "" + + def _get_global_suggestions(self) -> List[Tuple[str, str]]: + """Obtiene sugerencias globales para autocompletado""" + suggestions = [] + + try: + # Obtener contexto dinámico + dynamic_context = get_registered_base_context() + + for name, class_or_func in dynamic_context.items(): + if name[0].isupper(): # Prioritizar capitalizados + hint = f"Tipo o función: {name}" + if hasattr(class_or_func, '__doc__') and class_or_func.__doc__: + first_line = class_or_func.__doc__.strip().split('\n')[0] + hint = f"{name} - {first_line}" + suggestions.append((name, hint)) + + # Añadir funciones SymPy + sympy_functions = SympyHelper.PopupFunctionList() + if sympy_functions: + current_names = {s[0] for s in suggestions} + for fname, fhint in sympy_functions: + if fname not in current_names: + suggestions.append((fname, fhint)) + + except Exception as e: + self.logger.debug(f"Error obteniendo sugerencias globales: {e}") + + suggestions.sort(key=lambda x: x[0]) + return suggestions + + def _schedule_variable_autocomplete_improved(self): + """Programa el autocompletado de variables""" if self._autocomplete_active or self._popup_disabled_until_next_dot: return - # Verificar que estemos escribiendo (no solo navegando) + # Verificar que estemos escribiendo cursor = self.input_text.textCursor() cursor.select(QTextCursor.LineUnderCursor) current_line = cursor.selectedText().strip() @@ -1300,208 +1335,12 @@ class HybridCalculatorPySide6(QMainWindow): # Programar para 800ms después self._variable_popup_timer.start(800) - def _handle_dot_autocomplete(self): - """Maneja el autocompletado cuando se escribe un punto - VERSIÓN COMPLETA DE TKINTER""" - self._close_autocomplete_popup() - - # Obtener posición del cursor y línea actual - cursor = self.input_text.textCursor() - cursor_pos = cursor.position() - cursor.select(QTextCursor.LineUnderCursor) - line_text = cursor.selectedText() - - # Obtener posición en la línea - line_start = cursor.selectionStart() - char_pos_in_line = cursor_pos - line_start - - if char_pos_in_line == 0: - return - - # Guardar posición donde se activó el autocompletado - self._autocomplete_trigger_pos = cursor_pos - self._autocomplete_filter_text = "" - - # Obtener texto antes del punto - dot_char_index_in_line = char_pos_in_line - 1 - text_on_line_up_to_dot = line_text[: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("Punto en línea vacía. Ofreciendo sugerencias globales.") - suggestions = [] - - # Usar contexto dinámico del registro - try: - from type_registry import get_registered_base_context - 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: - pass - suggestions.append((name, hint)) - - except Exception as e: - self.logger.debug(f"Error obteniendo contexto dinámico: {e}") - suggestions = [("sin", "Función seno"), ("cos", "Función coseno")] - - # Añadir funciones de SympyHelper - try: - from sympy_helper import SympyTools as SympyHelper - 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 llamando SympyHelper.PopupFunctionList() para 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 - import re - 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"Expresión extraída '{obj_expr_str_candidate}' no es válida para autocompletado.") - return - - obj_expr_str = obj_expr_str_candidate - self.logger.debug(f"Autocompletado para objeto. Extraído: '{obj_expr_str}'") - - if not obj_expr_str: - return - - # 3. Caso especial para el módulo sympy - if obj_expr_str == "sympy": - try: - from sympy_helper import SympyTools as SympyHelper - methods = SympyHelper.PopupFunctionList() - if methods: - self._show_autocomplete_popup(methods, is_global_popup=False) - else: - self.logger.debug(f"SympyHelper.PopupFunctionList devolvió métodos vacíos.") - except Exception as e: - self.logger.debug(f"Error llamando SympyHelper.PopupFunctionList(): {e}") - return - - # 4. Preprocesar con BracketParser si es necesario - 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: - self.logger.debug(f"Preprocesado por BracketParser: '{original_for_debug}' -> '{obj_expr_str}'") - - # 5. Evaluar la expresión del objeto - eval_context = self.engine._get_full_context() - obj = None - try: - if not obj_expr_str.strip(): - return - self.logger.debug(f"Intentando evaluar: '{obj_expr_str}'") - obj = eval(obj_expr_str, eval_context) - self.logger.debug(f"Evaluación exitosa. Objeto: {type(obj)}, Valor: {obj}") - except Exception as e: - self.logger.debug(f"Error evaluando expresión de objeto '{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 _check_dot_removal(self): - """Verifica si se va a borrar el punto que activó el autocompletado""" - try: - cursor = self.input_text.textCursor() - cursor_pos = cursor.position() - - if cursor_pos > 0: - # Obtener el carácter anterior al cursor - cursor.setPosition(cursor_pos - 1) - cursor.setPosition(cursor_pos, QTextCursor.KeepAnchor) - prev_char = cursor.selectedText() - - # Si el carácter anterior es un punto, cerrar el popup - if prev_char == '.': - self._close_autocomplete_popup() - - except Exception: - # Error de posición, cerrar popup por seguridad - self._close_autocomplete_popup() - - def _handle_arrow_key(self, direction): - """Maneja las teclas de flecha cuando el popup está activo""" - if not self._autocomplete_active and not self._variable_popup_active: - return - - self._navigate_autocomplete_improved(direction) - - # Marcar tiempo de navegación para evitar filtrado inmediato - import time - self._last_navigation_time = time.time() - - def _handle_tab_key(self): - """Maneja la tecla TAB para seleccionar del popup""" - if self._autocomplete_active or self._variable_popup_active: - self._select_autocomplete() - - def _handle_escape_key(self): - """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 - - def _navigate_autocomplete_improved(self, direction): - """Navegación mejorada en el popup de autocompletado""" - if not self._autocomplete_listbox: - return - - current_row = self._autocomplete_listbox.currentRow() - row_count = self._autocomplete_listbox.count() - - if row_count == 0: - return - - if current_row == -1: - new_row = 0 if direction == 1 else row_count - 1 - else: - new_row = (current_row + direction) % row_count # Navegación circular - - # Actualizar selección - self._autocomplete_listbox.setCurrentRow(new_row) - self._selected_index = new_row - - def _show_variable_autocomplete(self): - """Muestra autocompletado de variables disponibles - VERSIÓN COMPLETA DE TKINTER""" + def _show_variable_autocomplete_improved(self): + """Muestra autocompletado de variables disponibles""" if self._autocomplete_active or self._variable_popup_active: return - # Verificar que aún estemos en una línea válida + # Verificar línea actual cursor = self.input_text.textCursor() cursor.select(QTextCursor.LineUnderCursor) current_line = cursor.selectedText().strip() @@ -1512,26 +1351,15 @@ class HybridCalculatorPySide6(QMainWindow): # Obtener variables del contexto try: context = self.engine._get_full_context() - symbol_table = getattr(self.engine, 'symbol_table', {}) - variables = [] - # Filtrar variables (excluir funciones built-in y módulos) - for name, value in {**context, **symbol_table}.items(): - 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 - is_sympy_symbol = hasattr(value, 'is_symbol') or 'sympy' in str(type(value)).lower() - - if (not is_underscore and - not is_callable and - (not has_module or is_sympy_symbol) and - not is_excluded): + # Filtrar variables + for name, value in context.items(): + if (not name.startswith('_') and + not callable(value) and + name not in ['sympy', 'math', 'numpy', 'plt', 'builtins']): - # Crear descripción del valor (más corta) + # Descripción del valor value_str = str(value) if len(value_str) > 20: value_str = value_str[:17] + "..." @@ -1541,359 +1369,56 @@ class HybridCalculatorPySide6(QMainWindow): if variables: variables.sort(key=lambda x: x[0]) - # Obtener texto actual para filtrado + # Filtrar por palabra actual words = current_line.split() - if words: last_word = words[-1] - - # 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 + (name, value) for name, value in variables if name.lower().startswith(last_word.lower()) and name != last_word ] if filtered_vars: - # Mostrar popup de variables self._show_variable_popup(filtered_vars) - + except Exception as e: - self.logger.debug(f"Error obteniendo variables para autocompletado: {e}") + self.logger.debug(f"Error obteniendo variables: {e}") - def _show_autocomplete_popup(self, suggestions, is_global_popup=False): - """Muestra popup de autocompletado modeless con filtrado - SISTEMA COMPLETO DE TKINTER""" - self._close_autocomplete_popup() - + def _show_autocomplete_popup(self, suggestions: List[Tuple[str, str]], is_global_popup: bool = False): + """Muestra popup de autocompletado""" if not suggestions: return - # Guardar sugerencias originales y estado + self._close_autocomplete_popup() + + # Guardar estado self._current_suggestions = suggestions.copy() self._is_global_popup = is_global_popup self._autocomplete_active = True - # Crear popup verdaderamente modeless - NO TOMA FOCO - self._autocomplete_popup = QWidget(None, Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) - self._autocomplete_popup.setAttribute(Qt.WA_ShowWithoutActivating, True) - self._autocomplete_popup.setFocusPolicy(Qt.NoFocus) - self._autocomplete_popup.setStyleSheet(""" - QWidget { - background-color: #3c3f41; - border: 1px solid #4fc3f7; - border-radius: 4px; - } - QListWidget { - background-color: #3c3f41; - color: #bbbbbb; - border: none; - font-family: 'Consolas'; - font-size: 10px; - } - QListWidget::item { - padding: 3px 8px; - border: none; - } - QListWidget::item:selected { - background-color: #007acc; - color: white; - } - """) + # Crear popup + self._autocomplete_popup = AutocompletePopup(self) + self._autocomplete_popup.set_suggestions(suggestions) + self._autocomplete_popup.item_selected.connect(self._on_autocomplete_selected) - layout = QVBoxLayout(self._autocomplete_popup) - layout.setContentsMargins(0, 0, 0, 0) - - # Crear listbox con nombre correcto para compatibilidad - SIN FOCO - self._autocomplete_listbox = QListWidget() - self._autocomplete_listbox.setMaximumHeight(150) - self._autocomplete_listbox.setFocusPolicy(Qt.NoFocus) - - # Llenar con sugerencias iniciales - self._populate_listbox(suggestions) - - if self._autocomplete_listbox.count() > 0: - self._autocomplete_listbox.setCurrentRow(0) - self._selected_index = 0 - - # Bindings solo para el listbox (no roba focus del input) - self._autocomplete_listbox.itemDoubleClicked.connect(self._select_autocomplete) - layout.addWidget(self._autocomplete_listbox) - - # Calcular tamaño - self._resize_popup() - - # Posicionar popup + # Posicionar cursor_rect = self.input_text.cursorRect() global_pos = self.input_text.mapToGlobal(cursor_rect.bottomLeft()) self._autocomplete_popup.move(global_pos) + self._autocomplete_popup.adjust_size() self._autocomplete_popup.show() - def _populate_listbox(self, suggestions): - """Llena el listbox con las sugerencias""" - if not hasattr(self, '_autocomplete_listbox') or not self._autocomplete_listbox: - return - - self._autocomplete_listbox.clear() - for name, hint in suggestions: - self._autocomplete_listbox.addItem(f"{name} — {hint}") - - def _resize_popup(self): - """Redimensiona el popup según el contenido y lo posiciona de forma modeless""" - if not hasattr(self, '_autocomplete_listbox') or not self._autocomplete_listbox: - return - - size = self._autocomplete_listbox.count() - if size == 0: - return - - # Calcular dimensiones - max_len = 20 - for i in range(size): - item_text = self._autocomplete_listbox.item(i).text() - max_len = max(max_len, len(item_text)) - - width = min(max_len * 8, 600) # Aproximación de ancho en píxeles - height = min(size * 20, 200) # Altura por ítem - - self._autocomplete_popup.setFixedSize(width, height) - - # Posicionar de forma modeless por debajo de la línea - self._position_popup_modeless() - - def _position_popup_modeless(self): - """Posiciona el popup de forma modeless por debajo de la línea actual de escritura""" - if not self._autocomplete_popup: - return - - # Obtener cursor y línea actual - cursor = self.input_text.textCursor() - cursor_rect = self.input_text.cursorRect(cursor) - - # Posición global de la línea actual - input_global_pos = self.input_text.mapToGlobal(cursor_rect.bottomLeft()) - - # Calcular posición debajo de la línea con un pequeño offset - popup_x = input_global_pos.x() - popup_y = input_global_pos.y() + 5 # 5px debajo de la línea - - # Verificar que no se salga de la pantalla - screen = QApplication.primaryScreen().geometry() - popup_size = self._autocomplete_popup.size() - - # Ajustar si se sale por la derecha - if popup_x + popup_size.width() > screen.width(): - popup_x = screen.width() - popup_size.width() - 10 - - # Ajustar si se sale por abajo - if popup_y + popup_size.height() > screen.height(): - # Mostrar por encima de la línea en su lugar - popup_y = input_global_pos.y() - popup_size.height() - 5 - - # Asegurar que no sea negativo - popup_x = max(0, popup_x) - popup_y = max(0, popup_y) - - self._autocomplete_popup.move(popup_x, popup_y) - - def _show_variable_popup(self, variables): - """Muestra popup de variables con estilo menos invasivo - VERSIÓN COMPLETA DE TKINTER""" - self._close_autocomplete_popup() - - if not variables: - return - - # Marcar como popup de variables activo + def _show_variable_popup(self, variables: List[Tuple[str, str]]): + """Muestra popup de variables""" self._variable_popup_active = True - self._autocomplete_active = False # No es el popup principal + self._autocomplete_active = False - # Crear popup verdaderamente modeless - NO TOMA FOCO - self._autocomplete_popup = QWidget(None, Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) - self._autocomplete_popup.setAttribute(Qt.WA_ShowWithoutActivating, True) - self._autocomplete_popup.setFocusPolicy(Qt.NoFocus) - self._autocomplete_popup.setStyleSheet(""" - QWidget { - background-color: #2d2d30; - border: 1px solid #c3e88d; - border-radius: 4px; - } - QListWidget { - background-color: #2d2d30; - color: #c9c9c9; - border: none; - font-family: 'Consolas'; - font-size: 10px; - } - QListWidget::item { - padding: 2px 6px; - border: none; - } - QListWidget::item:selected { - background-color: #4a4a4a; - color: #ffffff; - } - """) - - layout = QVBoxLayout(self._autocomplete_popup) - layout.setContentsMargins(0, 0, 0, 0) - - # Usar nombre correcto para compatibilidad - SIN FOCO - self._autocomplete_listbox = QListWidget() - self._autocomplete_listbox.setMaximumHeight(120) - self._autocomplete_listbox.setFocusPolicy(Qt.NoFocus) - - # Llenar con variables (formato más simple) - for name, value in variables: - self._autocomplete_listbox.addItem(f"{name} = {value}") - - if variables: - self._autocomplete_listbox.setCurrentRow(0) - self._selected_index = 0 - - # Solo doble-click para seleccionar (más discreto) - self._autocomplete_listbox.itemDoubleClicked.connect(self._select_variable) - - layout.addWidget(self._autocomplete_listbox) - - # 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) * 8, 320) - height = min(len(variables) * 18, 100) - - self._autocomplete_popup.setFixedSize(width, height) - - # Posicionar popup - cursor_rect = self.input_text.cursorRect() - global_pos = self.input_text.mapToGlobal(cursor_rect.bottomLeft()) - self._autocomplete_popup.move(global_pos) - self._autocomplete_popup.show() - else: - self._close_autocomplete_popup() - - def _select_variable(self): - """Selecciona una variable del popup de variables""" - if not self._autocomplete_listbox: - return - - current_item = self._autocomplete_listbox.currentItem() - if not current_item: - return - - # Obtener nombre de variable - selected_text = current_item.text() - var_name = selected_text.split(" = ")[0].strip() - - # Obtener posición de la palabra actual - cursor = self.input_text.textCursor() - cursor.select(QTextCursor.LineUnderCursor) - current_line = cursor.selectedText() - - # Encontrar la palabra que estamos completando - words = current_line.split() - if words: - last_word = words[-1] - # Buscar posición de la última palabra en la línea - word_start_pos = current_line.rfind(last_word) - if word_start_pos >= 0: - # Calcular posición absoluta - line_start = cursor.selectionStart() - abs_word_start = line_start + word_start_pos - abs_word_end = abs_word_start + len(last_word) - - # Reemplazar la palabra parcial con la variable completa - cursor.setPosition(abs_word_start) - cursor.setPosition(abs_word_end, QTextCursor.KeepAnchor) - cursor.insertText(var_name) - self.input_text.setTextCursor(cursor) - - # Cerrar popup - self._close_autocomplete_popup() - - def _select_autocomplete(self): - """Selecciona el item actual del autocompletado - VERSIÓN COMPLETA DE TKINTER""" - if not self._autocomplete_listbox: - return - - current_item = self._autocomplete_listbox.currentItem() - if not current_item: - return - - # Obtener texto seleccionado - selected_text = current_item.text() - - # 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 = self.input_text.textCursor() - cursor_pos = cursor.position() - - # Buscar el punto anterior - cursor.movePosition(QTextCursor.StartOfLine) - line_start = cursor.position() - line_text = cursor.block().text() - dot_pos = line_text.rfind('.', 0, cursor_pos - line_start) - - if dot_pos >= 0: - # Eliminar punto y texto filtrado - abs_dot_pos = line_start + dot_pos - cursor.setPosition(abs_dot_pos) - cursor.setPosition(cursor_pos, QTextCursor.KeepAnchor) - - # Insertar función (no variables en popup global) - insert_text = item_name + "()" - cursor.insertText(insert_text) - - # Posicionar cursor dentro de los paréntesis - new_pos = abs_dot_pos + len(item_name) + 1 - cursor.setPosition(new_pos) - self.input_text.setTextCursor(cursor) - else: - # Para popup de objeto/variables - cursor = self.input_text.textCursor() - current_pos = cursor.position() - - # Eliminar texto filtrado si existe - if self._autocomplete_filter_text: - start_pos = current_pos - len(self._autocomplete_filter_text) - cursor.setPosition(start_pos) - cursor.setPosition(current_pos, QTextCursor.KeepAnchor) - cursor.removeSelectedText() - current_pos = start_pos - - # Insertar según el tipo - if is_variable: - # Solo insertar el nombre de la variable - insert_text = item_name - cursor.insertText(insert_text) - cursor.setPosition(current_pos + len(item_name)) - else: - # Insertar método con paréntesis - insert_text = item_name + "()" - cursor.insertText(insert_text) - cursor.setPosition(current_pos + len(item_name) + 1) - - self.input_text.setTextCursor(cursor) - - # Cerrar popup y enfocar input - self._close_autocomplete_popup() + # Convertir formato para el popup + suggestions = [(name, f"= {value}") for name, value in variables] + self._show_autocomplete_popup(suggestions, is_global_popup=False) def _filter_autocomplete(self): - """Filtra las sugerencias basándose en el texto escrito después del punto""" + """Filtra las sugerencias del autocompletado""" if not self._autocomplete_active or not self._autocomplete_trigger_pos: return @@ -1901,19 +1426,13 @@ class HybridCalculatorPySide6(QMainWindow): cursor = self.input_text.textCursor() current_pos = cursor.position() - try: - # Calcular texto filtrado - if current_pos > self._autocomplete_trigger_pos: - cursor.setPosition(self._autocomplete_trigger_pos) - cursor.setPosition(current_pos, QTextCursor.KeepAnchor) - filter_text = cursor.selectedText().lower() - self._autocomplete_filter_text = filter_text - else: - self._autocomplete_filter_text = "" - except Exception: - # Posición inválida, cerrar popup - self._close_autocomplete_popup() - return + if current_pos > self._autocomplete_trigger_pos: + cursor.setPosition(self._autocomplete_trigger_pos) + cursor.setPosition(current_pos, QTextCursor.KeepAnchor) + filter_text = cursor.selectedText().lower() + self._autocomplete_filter_text = filter_text + else: + self._autocomplete_filter_text = "" # Filtrar sugerencias filtered = [] @@ -1921,59 +1440,563 @@ class HybridCalculatorPySide6(QMainWindow): if name.lower().startswith(self._autocomplete_filter_text): filtered.append((name, hint)) - if filtered: - # Actualizar listbox con sugerencias filtradas - self._populate_listbox(filtered) - if self._autocomplete_listbox.count() > 0: - self._autocomplete_listbox.setCurrentRow(0) - self._selected_index = 0 - self._resize_popup() + if filtered and self._autocomplete_popup: + self._autocomplete_popup.set_suggestions(filtered) + self._autocomplete_popup.adjust_size() else: - # No hay coincidencias, cerrar popup self._close_autocomplete_popup() - def _close_autocomplete_popup(self): - """Cierra popup de autocomplete y resetea estado - VERSIÓN COMPLETA DE TKINTER""" + def _handle_arrow_key(self, direction: int): + """Maneja navegación con flechas en el popup""" if self._autocomplete_popup: - try: - self._autocomplete_popup.close() - self._autocomplete_popup.deleteLater() - except: - pass + self._autocomplete_popup.navigate(direction) + self._last_navigation_time = time.time() + + def _handle_tab_key(self): + """Maneja tecla TAB para seleccionar""" + self._select_autocomplete() + + def _handle_escape_key(self): + """Maneja tecla ESC para cerrar popup""" + self._close_autocomplete_popup() + if self._autocomplete_active: + self._popup_disabled_until_next_dot = True + + def _select_autocomplete(self): + """Selecciona el item actual del autocompletado""" + if not self._autocomplete_popup: + return + + selected_text = self._autocomplete_popup.get_selected_text() + if selected_text: + self._insert_autocomplete_text(selected_text) + + self._close_autocomplete_popup() + + def _on_autocomplete_selected(self, text: str): + """Callback cuando se selecciona un item del popup""" + self._insert_autocomplete_text(text) + self._close_autocomplete_popup() + + def _insert_autocomplete_text(self, text: str): + """Inserta el texto seleccionado del autocompletado""" + cursor = self.input_text.textCursor() + + # Para popup de variables + if self._variable_popup_active: + # Reemplazar palabra actual + cursor.select(QTextCursor.WordUnderCursor) + cursor.insertText(text) + return + + # Para popup global (después de punto solo) + if self._is_global_popup: + # Eliminar el punto y añadir función + cursor.setPosition(self._autocomplete_trigger_pos - 1) + cursor.setPosition(self._autocomplete_trigger_pos, QTextCursor.KeepAnchor) + cursor.insertText(text + "()") + # Posicionar cursor dentro de paréntesis + cursor.setPosition(self._autocomplete_trigger_pos - 1 + len(text) + 1) + self.input_text.setTextCursor(cursor) + else: + # Para métodos de objeto + if self._autocomplete_filter_text: + # Eliminar texto filtrado + start_pos = cursor.position() - len(self._autocomplete_filter_text) + cursor.setPosition(start_pos) + cursor.setPosition(start_pos + len(self._autocomplete_filter_text), QTextCursor.KeepAnchor) + cursor.removeSelectedText() + + # Insertar método + cursor.insertText(text + "()") + cursor.setPosition(cursor.position() - 1) + self.input_text.setTextCursor(cursor) + + def _check_dot_removal(self): + """Verifica si se borró el punto que activó el autocompletado""" + cursor = self.input_text.textCursor() + if cursor.position() > 0: + cursor.setPosition(cursor.position() - 1) + cursor.setPosition(cursor.position() + 1, QTextCursor.KeepAnchor) + if cursor.selectedText() == '.': + self._close_autocomplete_popup() + + def _close_autocomplete_popup(self): + """Cierra el popup de autocompletado""" + if self._autocomplete_popup: + self._autocomplete_popup.close() self._autocomplete_popup = None - if hasattr(self, '_autocomplete_listbox'): - self._autocomplete_listbox = None - - # Resetear estado del autocompletado + # Resetear estado self._autocomplete_active = False self._variable_popup_active = False self._autocomplete_trigger_pos = None self._autocomplete_filter_text = "" self._current_suggestions = [] - self._selected_index = 0 + + # ========== MANEJO DE PANEL LATEX ========== + + def _toggle_latex_panel(self): + """Muestra/oculta el panel LaTeX""" + self.latex_panel_visible = not self.latex_panel_visible - # Detener timers - if hasattr(self, '_variable_popup_timer'): - self._variable_popup_timer.stop() + if self.latex_panel_visible: + self.latex_panel.show() + self.latex_button.setChecked(True) + # Re-renderizar ecuaciones si hay + if hasattr(self, '_latex_equations') and self._latex_equations: + for eq in self._latex_equations: + self.latex_panel.add_equation(eq['type'], eq['content']) + else: + self.latex_panel.hide() + self.latex_button.setChecked(False) + + # Guardar estado + self.settings["latex_panel_visible"] = self.latex_panel_visible + + # ========== FUNCIONES DE MENÚ ========== + + def new_session(self): + """Inicia una nueva sesión""" + self.clear_input() + self.clear_output() + self.latex_panel.clear_equations() + if hasattr(self, '_latex_equations'): + self._latex_equations.clear() + self._update_status("✨ Nueva sesión iniciada") + + def load_file(self): + """Carga archivo en el editor""" + filepath, _ = QFileDialog.getOpenFileName( + self, "Cargar archivo", "", + "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.setPlainText(content) + self._evaluate_and_update() + self._update_status(f"📁 Archivo cargado: {Path(filepath).name}") + + except Exception as e: + QMessageBox.critical(self, "Error", f"No se pudo cargar el archivo:\n{e}") + + def save_file(self): + """Guarda contenido del editor""" + filepath, _ = QFileDialog.getSaveFileName( + self, "Guardar archivo", "", + "Archivos de texto (*.txt);;Archivos Python (*.py);;Todos los archivos (*.*)" + ) + + if filepath: + try: + content = self.input_text.toPlainText() + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + + self._update_status(f"💾 Archivo guardado: {Path(filepath).name}") + + except Exception as e: + QMessageBox.critical(self, "Error", f"No se pudo guardar el archivo:\n{e}") + + def clear_input(self): + """Limpia panel de entrada""" + self.input_text.clear() + self._clear_output() + + def clear_output(self): + """Limpia panel de salida y LaTeX""" + self._clear_output() + self.latex_panel.clear_equations() + if hasattr(self, '_latex_equations'): + self._latex_equations.clear() + + def clear_history(self): + """Limpia el archivo de historial""" + try: + if os.path.exists(self.HISTORY_FILE): + os.remove(self.HISTORY_FILE) + self._update_status("✓ Historial limpiado") + except Exception as e: + QMessageBox.critical(self, "Error", f"No se pudo limpiar el historial:\n{e}") + + def reload_types(self): + """Recarga el sistema de tipos""" + try: + self.logger.info("Recargando sistema de tipos...") + self._setup_dynamic_helpers() + self._evaluate_and_update() + self._update_status("✓ Sistema de tipos recargado") + except Exception as e: + self.logger.error(f"Error recargando tipos: {e}") + QMessageBox.critical(self, "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) +""" + + self._show_info_dialog("Información del Sistema", info_text) + + except Exception as e: + QMessageBox.critical(self, "Error", f"Error obteniendo información:\n{e}") + + def show_types_syntax(self): + """Muestra sintaxis de tipos disponibles""" + try: + types_info = self.engine.get_available_types() + syntax_text = "SINTAXIS DE TIPOS DISPONIBLES\n\n" + + # Aquí iría el código para mostrar sintaxis + # Similar al original pero adaptado para PySide6 + + self._show_info_dialog("Sintaxis de Tipos", syntax_text) + + except Exception as e: + QMessageBox.critical(self, "Error", f"Error obteniendo sintaxis:\n{e}") + + def show_quick_guide(self): + """Muestra guía rápida""" + 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_info_dialog("Guía Rápida", guide) + + def show_syntax_help(self): + """Muestra ayuda de sintaxis""" + 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_info_dialog("Sintaxis", syntax) + + def show_sympy_functions(self): + """Muestra funciones SymPy disponibles""" + 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_info_dialog("Funciones SymPy", functions) + + def show_about(self): + """Muestra información sobre la aplicación""" + about = """Calculadora MAV - CAS Híbrido + +Versión: 2.1 PySide6 (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 + +Desarrollado para cálculo matemático avanzado +con soporte especializado para redes, +programación y análisis numérico. +""" + + QMessageBox.about(self, "Acerca de", about) + + def _show_info_dialog(self, title: str, content: str): + """Muestra diálogo de información con scroll""" + dialog = QMessageBox(self) + dialog.setWindowTitle(title) + dialog.setIcon(QMessageBox.Information) + dialog.setText(content[:200] + "..." if len(content) > 200 else content) + dialog.setDetailedText(content) + dialog.exec() + + def _diagnose_mathjax(self): + """Ejecuta diagnóstico de MathJax""" + if not hasattr(self.latex_panel, '_webview_available') or not self.latex_panel._webview_available: + QMessageBox.warning(self, "Diagnóstico", "Panel LaTeX no usa WebEngine (usando fallback)") + return + + # Aquí iría el código de diagnóstico + # Por ahora solo mostrar estado + status = "WebEngine disponible" if self.latex_panel._webview_available else "Usando fallback HTML" + equations = len(self._latex_equations) if hasattr(self, '_latex_equations') else 0 + + info = f"""DIAGNÓSTICO MATHJAX + +Estado: {status} +Ecuaciones en memoria: {equations} +Panel visible: {self.latex_panel_visible} + +Para depuración completa, revise la consola del navegador +en el WebEngineView. +""" + + self._show_info_dialog("Diagnóstico MathJax", info) + + def _show_latex_panel_status(self): + """Muestra estado del panel LaTeX""" + panel_exists = hasattr(self, 'latex_panel') + panel_visible = self.latex_panel_visible if panel_exists else False + webview_available = self.latex_panel._webview_available if panel_exists else False + equations_count = len(self._latex_equations) if hasattr(self, '_latex_equations') else 0 + + status_message = f"""ESTADO DEL PANEL LATEX + +COMPONENTES: +• Panel creado: {'✓' if panel_exists else '✗'} +• Panel visible: {'✓' if panel_visible else '✗'} +• WebEngine disponible: {'✓' if webview_available else '✗'} + +CONTENIDO: +• Ecuaciones en memoria: {equations_count} + +PARA SOLUCIONAR: +1. Si las ecuaciones están en memoria pero no se ven: + → Cerrar y reabrir el panel LaTeX +2. Si WebEngine no está disponible: + → Instalar con: pip install PySide6-WebEngine +""" + + self._show_info_dialog("Estado Panel LaTeX", status_message) + + # ========== UTILIDADES ========== + + def _obtener_ayuda(self, input_str: str) -> Optional[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}") + return None + + def _update_status(self, message: str, timeout: int = 0): + """Actualiza la barra de estado""" + self.status_bar.showMessage(message, timeout) + + 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: + pass + return { + "window_geometry": None, + "splitter_sizes": None, + "debug_mode": False, + "latex_panel_visible": True + } + + def _save_settings(self): + """Guarda configuraciones""" + try: + # Guardar geometría + geometry = self.geometry() + self.settings["window_geometry"] = { + "x": geometry.x(), + "y": geometry.y(), + "width": geometry.width(), + "height": geometry.height() + } + + # Guardar tamaños del splitter + if hasattr(self, 'main_splitter'): + self.settings["splitter_sizes"] = self.main_splitter.sizes() + + self.settings["latex_panel_visible"] = self.latex_panel_visible + self.settings["debug_mode"] = self.debug + + 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: + self.logger.error(f"Error guardando configuración: {e}") + + def _load_history(self): + """Carga historial de entrada""" + 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.setPlainText(content) + # Evaluación inicial + QTimer.singleShot(100, self._evaluate_and_update) + except Exception as e: + self.logger.error(f"Error cargando historial: {e}") + + def _save_history(self): + """Guarda historial de entrada""" + try: + content = self.input_text.toPlainText() + 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}") + + def _restore_geometry(self): + """Restaura geometría guardada""" + try: + geom = self.settings.get("window_geometry") + if geom and isinstance(geom, dict): + self.setGeometry(geom["x"], geom["y"], geom["width"], geom["height"]) + + # Restaurar splitter + sizes = self.settings.get("splitter_sizes") + if sizes and hasattr(self, 'main_splitter'): + self.main_splitter.setSizes(sizes) + + except Exception as e: + self.logger.warning(f"No se pudo restaurar geometría: {e}") + + def closeEvent(self, event): + """Maneja el cierre de la aplicación""" + try: + # Guardar configuración + self._save_settings() + self._save_history() + + # Cerrar ventanas interactivas + if self.interactive_manager: + self.interactive_manager.close_all_windows() + + # Cerrar popup si existe + self._close_autocomplete_popup() + + event.accept() + + except Exception as e: + self.logger.error(f"Error durante cierre: {e}") + event.accept() def main(): """Función principal""" app = QApplication(sys.argv) app.setApplicationName("Calculadora MAV") - app.setApplicationVersion("2.0.0") + app.setOrganizationName("MAV") # Configurar estilo app.setStyle('Fusion') - # Crear y mostrar ventana principal + # Crear ventana principal window = HybridCalculatorPySide6() window.show() - # Ejecutar aplicación sys.exit(app.exec()) if __name__ == "__main__": - main() \ No newline at end of file + main()