Calc/main_calc_app_pyside6.py

1979 lines
78 KiB
Python

"""
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
"""
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 PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QTextEdit, QPlainTextEdit, QSplitter, QPushButton, QLabel,
QFrame, QMenuBar, QStatusBar, QMessageBox, QFileDialog,
QScrollArea, QSizePolicy, QListWidget, QListWidgetItem,
QCompleter, QAbstractItemView
)
from PySide6.QtCore import (
Qt, QTimer, QThread, QObject, Signal, QUrl, QSize
)
from PySide6.QtGui import (
QFont, QTextCursor, QTextCharFormat, QColor, QIcon,
QTextDocument, QSyntaxHighlighter, QTextFormat,
QKeySequence, QShortcut, QFontMetrics
)
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWebEngineCore import QWebEngineSettings
# Importar componentes del CAS híbrido
from main_evaluation_puro import PureAlgebraicEngine, EvaluationResult
from tl_popup_pyside6 import InteractiveResultManager, PlotResult
from type_registry import get_registered_helper_functions, get_registered_base_context
import sympy
from sympy_helper import SympyTools as SympyHelper
class 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"""
def __init__(self, parent=None):
super().__init__(parent)
self.setLineWrapMode(QPlainTextEdit.NoWrap)
class MathJaxPanel(QWebEngineView):
"""Panel MathJax mejorado con mejor parsing de LaTeX"""
def __init__(self, parent=None):
super().__init__(parent)
self.equations = []
self.setup_webview()
def setup_webview(self):
"""Configura el WebView con MathJax"""
self.load_mathjax_base()
def load_mathjax_base(self):
"""Carga la página base con MathJax mejorado"""
html_content = self.generate_base_html()
self.setHtml(html_content)
def generate_base_html(self):
"""Genera HTML base con MathJax y parsing mejorado de LaTeX"""
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Panel LaTeX</title>
<script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
<script>
window.MathJax = {
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']],
displayMath: [['$$', '$$'], ['\\[', '\\]']],
processEscapes: true,
processEnvironments: true,
tags: 'ams',
// Mejorar el parsing de divisiones y funciones
macros: {
frac: ['\\\\frac{#1}{#2}', 2],
sqrt: ['\\\\sqrt{#1}', 1],
sin: '\\\\sin',
cos: '\\\\cos',
tan: '\\\\tan',
log: '\\\\log',
ln: '\\\\ln',
exp: '\\\\exp'
}
},
chtml: {
scale: 1.1,
minScale: 0.5,
matchFontHeight: false,
displayAlign: 'left',
displayIndent: '0em'
},
startup: {
ready: () => {
MathJax.startup.defaultReady();
console.log('MathJax cargado exitosamente');
}
},
options: {
renderActions: {
addMenu: [0, '', '']
}
}
};
</script>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1e1e1e, #252526);
color: #d4d4d4;
margin: 0;
padding: 20px;
line-height: 1.6;
}
.equation-block {
background: rgba(45, 45, 48, 0.8);
border-left: 4px solid;
margin: 12px 0;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
transition: all 0.2s ease;
}
.equation-block:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.comment {
border-left-color: #6a9955;
background: rgba(106, 153, 85, 0.1);
}
.comment .equation-type { color: #6a9955; }
.assignment {
border-left-color: #4fc3f7;
background: rgba(79, 195, 247, 0.1);
}
.assignment .equation-type { color: #4fc3f7; }
.equation {
border-left-color: #82aaff;
background: rgba(130, 170, 255, 0.1);
}
.equation .equation-type { color: #82aaff; }
.symbolic {
border-left-color: #c3e88d;
background: rgba(195, 232, 141, 0.1);
}
.symbolic .equation-type { color: #c3e88d; }
.equation-type {
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
opacity: 0.8;
}
.math-display {
font-size: 16px;
line-height: 1.5;
}
.comment-text {
font-style: italic;
color: #6a9955;
background: none;
border: none;
padding: 5px 0;
}
#equations-container {
max-height: calc(100vh - 60px);
overflow-y: auto;
}
.info-message {
text-align: center;
padding: 40px 20px;
color: #888;
font-style: italic;
}
/* Scrollbar personalizado */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: rgba(45, 45, 48, 0.5);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(79, 195, 247, 0.6);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(79, 195, 247, 0.8);
}
</style>
</head>
<body>
<div id="equations-container">
<div class="info-message">
📐 Panel de Ecuaciones LaTeX<br/>
<small>Las ecuaciones, asignaciones y comentarios aparecerán aquí</small>
</div>
</div>
<script>
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 = '';
}
var equation = document.createElement('div');
equation.className = 'equation-block ' + type;
// Mejorar el processing de LaTeX
var processedContent = preprocessLatex(content);
if (type === 'comment') {
equation.innerHTML =
'<div class="equation-type">Comentario</div>' +
'<div class="comment-text">' + escapeHtml(content) + '</div>';
} else {
equation.innerHTML =
'<div class="equation-type">' + getTypeLabel(type) + '</div>' +
'<div class="math-display">$$' + processedContent + '$$</div>';
}
container.appendChild(equation);
// Re-renderizar MathJax
if (window.MathJax && MathJax.typesetPromise) {
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);
}
});
}
}
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 = '<div class="info-message">📐 Panel de Ecuaciones LaTeX<br/><small>Las ecuaciones, asignaciones y comentarios aparecerán aquí</small></div>';
}
</script>
</body>
</html>"""
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})
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);
"""
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)
def clear_equations(self):
"""Limpia todas las ecuaciones"""
self.equations.clear()
self.page().runJavaScript("clearEquations();")
class HybridCalculatorPySide6(QMainWindow):
"""Aplicación principal del CAS híbrido - Diseño minimalista de 3 paneles con persistencia"""
SETTINGS_FILE = "hybrid_calc_settings_pyside6.json"
HISTORY_FILE = "hybrid_calc_history_pyside6.txt"
def __init__(self):
super().__init__()
# Configurar logging
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s: %(message)s')
self.logger = logging.getLogger(__name__)
# ========== USAR EL MOTOR ORIGINAL SIN CAMBIOS ==========
self.engine = PureAlgebraicEngine()
self.interactive_manager = None
# Variables de configuración con persistencia
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
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._current_suggestions = []
self._is_global_popup = False
self._selected_index = 0
self._last_navigation_time = 0
self._last_input_change = 0
# Timers para autocompletado
self._variable_popup_timer = QTimer()
self._variable_popup_timer.setSingleShot(True)
self._variable_popup_timer.timeout.connect(self._show_variable_autocomplete)
# ========== CONFIGURAR HELPERS DINÁMICOS (COMO EN ORIGINAL) ==========
self._setup_dynamic_helpers()
# Configurar interfaz
self.setup_ui()
self.setup_shortcuts()
self.load_history()
# Configurar manager interactivo
self.setup_interactive_manager()
# 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}")
def _setup_dynamic_helpers(self):
"""Configura helpers dinámicamente desde el registro de tipos - COMO EN ORIGINAL"""
try:
self.HELPERS = get_registered_helper_functions()
self.HELPERS.append(SympyHelper.Helper)
self.logger.info(f"Helpers dinámicos cargados: {len(self.HELPERS)}")
except Exception as e:
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"""
self.setWindowTitle("Calculadora MAV - CAS Híbrido")
self.setGeometry(100, 100, 1400, 800)
self.setStyleSheet(self.get_minimal_dark_theme())
# ========== SPLITTER PRINCIPAL CON 3 PANELES ==========
main_splitter = QSplitter(Qt.Horizontal)
self.setCentralWidget(main_splitter)
# ========== PANEL 1: ENTRADA ==========
self.input_text = LineNumberedPlainTextEdit()
self.input_text.setFont(QFont("Consolas", 11))
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
# Configurar highlighter
self.highlighter = MathInputHighlighter(self.input_text.document())
# ========== PANEL 2: SALIDA (SINCRONIZADO 1:1) ==========
self.output_text = SynchronizedTextEdit()
self.output_text.setFont(QFont("Consolas", 11))
self.output_text.setReadOnly(True)
# 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
)
# ========== PANEL 3: MATHJAX (COLAPSABLE) ==========
self.latex_panel = MathJaxPanel()
# ========== AÑADIR PANELES AL SPLITTER ==========
main_splitter.addWidget(self.input_text)
main_splitter.addWidget(self.output_text)
main_splitter.addWidget(self.latex_panel)
# 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
# ========== 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)
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)
# Crear formato para link clickeable
link_format = QTextCharFormat()
link_format.setForeground(QColor("#4fc3f7"))
link_format.setUnderlineStyle(QTextCharFormat.SingleUnderline)
link_format.setFontUnderline(True)
# Insertar texto con formato de link
cursor.insertText(display_text, link_format)
# 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 = {}
# 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)
# 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
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)
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"""
<div style="padding: 10px; border: 1px solid #4fc3f7; border-radius: 4px; margin: 5px;">
<h4 style="color: #4fc3f7; margin: 0 0 10px 0;">📊 {plot_result.plot_type.title()}</h4>
<p style="color: #d4d4d4; margin: 0;">Expresión: {plot_result.original_expression}</p>
<p style="color: #89ddff; margin: 5px 0 0 0; font-size: 12px; cursor: pointer;"
onclick="alert('Click para abrir ventana de edición')">
🔗 Click para editar y visualizar
</p>
</div>
"""
# 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
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)
except Exception as e:
self.logger.error(f"Error añadiendo al panel LaTeX: {e}")
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 _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 get_minimal_dark_theme(self):
"""Tema oscuro minimalista"""
return """
QMainWindow {
background-color: #1e1e1e;
color: #d4d4d4;
}
QPlainTextEdit, QTextEdit {
background-color: #252526;
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;
}
QMenu {
background-color: #2d2d30;
color: #d4d4d4;
border: 1px solid #3c3c3c;
}
QMenu::item:selected {
background-color: #094771;
}
QStatusBar {
background-color: #007acc;
color: white;
border: none;
}
QSplitter::handle {
background-color: #4fc3f7;
border-radius: 1px;
}
QSplitter::handle:horizontal {
width: 3px;
margin: 2px 0;
}
QSplitter::handle:horizontal:hover {
background-color: #82aaff;
}
"""
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)
def create_menu_bar(self):
"""Crea la barra de menú minimalista"""
menubar = self.menuBar()
# Menú Archivo
file_menu = menubar.addMenu("Archivo")
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)
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",
"""
<h2>Calculadora MAV - CAS Híbrido</h2>
<p><b>Versión PySide6 con diseño minimalista</b></p>
<p>Sistema algebraico computacional híbrido con 3 paneles:</p>
<ul>
<li>Panel de entrada (izquierda)</li>
<li>Panel de resultados 1:1 (centro)</li>
<li>Panel LaTeX/MathJax (derecha)</li>
</ul>
<p>Motor algebraico: <b>SymPy</b> con tipos personalizados</p>
"""
)
def load_settings(self) -> Dict[str, Any]:
"""Carga configuración desde archivo"""
try:
if Path(self.SETTINGS_FILE).exists():
with open(self.SETTINGS_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
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
}
def save_settings(self):
"""Guarda configuración a archivo con geometría completa"""
try:
# Actualizar configuraciones de UI
self.settings['latex_panel_visible'] = self.latex_panel_visible
self.settings['debug_mode'] = self.debug
# Guardar geometría de ventana
geometry = self.geometry()
self.settings['window_geometry'] = {
'x': geometry.x(),
'y': geometry.y(),
'width': geometry.width(),
'height': geometry.height()
}
# 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)
except Exception as e:
self.logger.error(f"Error guardando configuración: {e}")
def load_history(self):
"""Carga historial desde archivo"""
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)
except Exception as e:
self.logger.warning(f"No se pudo cargar historial: {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}")
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):
if event.key() == Qt.Key_Up:
self._handle_arrow_key(-1)
event.accept()
return
elif event.key() == Qt.Key_Down:
self._handle_arrow_key(1)
event.accept()
return
elif event.key() == Qt.Key_Tab:
self._handle_tab_key()
event.accept()
return
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
# Detectar backspace para cerrar popup de funciones 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 el carácter
self._on_key_release(event)
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
# Verificar si acabamos de navegar (evitar filtrado inmediato)
import time
just_navigated = (time.time() - self._last_navigation_time) < 0.1
# Manejar autocompletado con punto
if event.text() == '.' and not self._popup_disabled_until_next_dot:
# Cerrar popup de variables si está activo
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:
self._filter_autocomplete()
# Marcar tiempo del último cambio de input
if event.text() and event.text().isprintable():
self._last_input_change = time.time()
# Programar autocompletado de variables (nuevo sistema)
if not self._autocomplete_active and not self._popup_disabled_until_next_dot:
self._schedule_variable_autocomplete()
def _schedule_variable_autocomplete(self):
"""Programa el autocompletado de variables mientras se escribe"""
if self._autocomplete_active or self._popup_disabled_until_next_dot:
return
# Verificar que estemos escribiendo (no solo navegando)
cursor = self.input_text.textCursor()
cursor.select(QTextCursor.LineUnderCursor)
current_line = cursor.selectedText().strip()
if not current_line or current_line.endswith('.'):
return
# 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"""
if self._autocomplete_active or self._variable_popup_active:
return
# Verificar que aún estemos en una línea válida
cursor = self.input_text.textCursor()
cursor.select(QTextCursor.LineUnderCursor)
current_line = cursor.selectedText().strip()
if not current_line or current_line.endswith('.'):
return
# 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):
# Crear descripción del valor (más corta)
value_str = str(value)
if len(value_str) > 20:
value_str = value_str[:17] + "..."
variables.append((name, value_str))
if variables:
variables.sort(key=lambda x: x[0])
# Obtener texto actual para filtrado
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
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}")
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()
if not suggestions:
return
# Guardar sugerencias originales y 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;
}
""")
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
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()
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
self._variable_popup_active = True
self._autocomplete_active = False # No es el popup principal
# 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()
def _filter_autocomplete(self):
"""Filtra las sugerencias basándose en el texto escrito después del punto"""
if not self._autocomplete_active or not self._autocomplete_trigger_pos:
return
# Obtener texto escrito después del punto
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
# Filtrar sugerencias
filtered = []
for name, hint in self._current_suggestions:
if name.lower().startswith(self._autocomplete_filter_text):
filtered.append((name, hint))
if filtered:
# Actualizar listbox con sugerencias filtradas
self._populate_listbox(filtered)
if self._autocomplete_listbox.count() > 0:
self._autocomplete_listbox.setCurrentRow(0)
self._selected_index = 0
self._resize_popup()
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"""
if self._autocomplete_popup:
try:
self._autocomplete_popup.close()
self._autocomplete_popup.deleteLater()
except:
pass
self._autocomplete_popup = None
if hasattr(self, '_autocomplete_listbox'):
self._autocomplete_listbox = None
# Resetear estado del autocompletado
self._autocomplete_active = False
self._variable_popup_active = False
self._autocomplete_trigger_pos = None
self._autocomplete_filter_text = ""
self._current_suggestions = []
self._selected_index = 0
# Detener timers
if hasattr(self, '_variable_popup_timer'):
self._variable_popup_timer.stop()
def main():
"""Función principal"""
app = QApplication(sys.argv)
app.setApplicationName("Calculadora MAV")
app.setApplicationVersion("2.0.0")
# Configurar estilo
app.setStyle('Fusion')
# Crear y mostrar ventana principal
window = HybridCalculatorPySide6()
window.show()
# Ejecutar aplicación
sys.exit(app.exec())
if __name__ == "__main__":
main()