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(""" + +
{content}
Expresión: {plot_result.original_expression}
-- 🔗 Click para editar y visualizar -
-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()