1979 lines
78 KiB
Python
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() |