1523 lines
59 KiB
Python
1523 lines
59 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 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("#89ddff"))
|
|
self.highlighting_rules.append((r'\b\d+\.?\d*\b', number_format))
|
|
|
|
# Funciones matemáticas
|
|
function_format = QTextCharFormat()
|
|
function_format.setForeground(QColor("#82aaff"))
|
|
function_format.setFontWeight(QFont.Bold)
|
|
functions = [
|
|
'sin', 'cos', 'tan', 'log', 'ln', 'exp', 'sqrt', 'abs',
|
|
'solve', 'diff', 'integrate', 'limit', 'series', 'factor',
|
|
'expand', 'simplify', 'Matrix', 'det', 'inv'
|
|
]
|
|
for func in functions:
|
|
pattern = rf'\b{func}\b'
|
|
self.highlighting_rules.append((pattern, function_format))
|
|
|
|
# Variables
|
|
variable_format = QTextCharFormat()
|
|
variable_format.setForeground(QColor("#c3e88d"))
|
|
self.highlighting_rules.append((r'\b[a-zA-Z_][a-zA-Z0-9_]*\b', variable_format))
|
|
|
|
# Operadores
|
|
operator_format = QTextCharFormat()
|
|
operator_format.setForeground(QColor("#ff6b6b"))
|
|
operator_format.setFontWeight(QFont.Bold)
|
|
self.highlighting_rules.append((r'[+\-*/=<>!&|^]', operator_format))
|
|
|
|
# Paréntesis y corchetes
|
|
bracket_format = QTextCharFormat()
|
|
bracket_format.setForeground(QColor("#f78c6c"))
|
|
bracket_format.setFontWeight(QFont.Bold)
|
|
self.highlighting_rules.append((r'[\[\](){}]', bracket_format))
|
|
|
|
def highlightBlock(self, text):
|
|
"""Aplica el resaltado al bloque de texto"""
|
|
for pattern, format_obj in self.highlighting_rules:
|
|
expression = re.compile(pattern)
|
|
for match in expression.finditer(text):
|
|
start, end = match.span()
|
|
self.setFormat(start, end - start, format_obj)
|
|
|
|
|
|
class SynchronizedTextEdit(QTextEdit):
|
|
"""Editor de texto que mantiene sincronización línea por línea"""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setLineWrapMode(QTextEdit.NoWrap)
|
|
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
|
|
def sync_scroll_with(self, other_widget):
|
|
"""Sincroniza el scroll con otro widget"""
|
|
self.verticalScrollBar().valueChanged.connect(
|
|
other_widget.verticalScrollBar().setValue
|
|
)
|
|
|
|
|
|
class LineNumberedPlainTextEdit(QPlainTextEdit):
|
|
"""Editor de texto plano con numeración de líneas implícita"""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setLineWrapMode(QPlainTextEdit.NoWrap)
|
|
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
|
|
|
|
class MathJaxPanel(QWebEngineView):
|
|
"""Panel web para renderizado de LaTeX con MathJax - Panel colapsable a la derecha"""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.equations = []
|
|
self.setup_webview()
|
|
self.load_mathjax_base()
|
|
|
|
def setup_webview(self):
|
|
"""Configura el webview"""
|
|
self.setMinimumWidth(300)
|
|
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
|
|
self.settings().setAttribute(QWebEngineSettings.LocalContentCanAccessRemoteUrls, True)
|
|
self.settings().setAttribute(QWebEngineSettings.LocalContentCanAccessFileUrls, True)
|
|
|
|
def load_mathjax_base(self):
|
|
"""Carga el HTML base con MathJax"""
|
|
html_content = self.generate_base_html()
|
|
self.setHtml(html_content)
|
|
|
|
def generate_base_html(self):
|
|
"""Genera el HTML base con MathJax configurado"""
|
|
return """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>MathJax Panel</title>
|
|
<style>
|
|
body {
|
|
font-family: 'Consolas', 'Courier New', monospace;
|
|
background-color: #1e1e1e;
|
|
color: #d4d4d4;
|
|
margin: 4px;
|
|
padding: 0;
|
|
line-height: 1.2;
|
|
font-size: 13px;
|
|
}
|
|
.equation {
|
|
margin: 2px 0;
|
|
padding: 4px 6px;
|
|
background-color: #2d2d30;
|
|
border-left: 2px solid #4fc3f7;
|
|
border-radius: 2px;
|
|
transition: all 0.15s ease;
|
|
}
|
|
.equation:hover {
|
|
background-color: #363636;
|
|
border-left-color: #82aaff;
|
|
}
|
|
.math-content {
|
|
font-size: 0.9em;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
.assignment { border-left-color: #c3e88d; }
|
|
.assignment .equation-type { color: #c3e88d; }
|
|
.symbolic { border-left-color: #f78c6c; }
|
|
.symbolic .equation-type { color: #f78c6c; }
|
|
.comment {
|
|
border-left-color: #696969;
|
|
background-color: #252526;
|
|
}
|
|
.comment .equation-type { color: #696969; }
|
|
</style>
|
|
<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
|
|
},
|
|
chtml: {
|
|
scale: 1.0,
|
|
minScale: 0.7
|
|
},
|
|
startup: {
|
|
ready: function () {
|
|
MathJax.startup.defaultReady();
|
|
}
|
|
}
|
|
};
|
|
|
|
function addEquation(type, content) {
|
|
const container = document.getElementById('equations-container');
|
|
if (!container) {
|
|
document.body.innerHTML = '<div id="equations-container"></div>';
|
|
}
|
|
|
|
const equationDiv = document.createElement('div');
|
|
equationDiv.className = 'equation ' + type;
|
|
equationDiv.innerHTML = `<div class="math-content">$$${content}$$</div>`;
|
|
|
|
document.getElementById('equations-container').appendChild(equationDiv);
|
|
|
|
if (window.MathJax && MathJax.typesetPromise) {
|
|
MathJax.typesetPromise([equationDiv]).catch(function (err) {
|
|
console.log('MathJax error:', err.message);
|
|
});
|
|
}
|
|
}
|
|
|
|
function clearEquations() {
|
|
const container = document.getElementById('equations-container');
|
|
if (container) {
|
|
container.innerHTML = '';
|
|
}
|
|
}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<div id="equations-container"></div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
def add_equation(self, equation_type: str, latex_content: str):
|
|
"""Añade una ecuación al panel"""
|
|
self.equations.append({'type': equation_type, 'content': latex_content})
|
|
# Escapar backticks en el contenido LaTeX
|
|
escaped_content = latex_content.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"""
|
|
|
|
SETTINGS_FILE = "hybrid_calc_settings.json"
|
|
HISTORY_FILE = "hybrid_calc_history.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
|
|
self.settings = self.load_settings()
|
|
self.debug = self.settings.get("debug_mode", False)
|
|
|
|
# Variables de estado UI
|
|
self.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
|
|
|
|
# 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()
|
|
|
|
self.logger.info("✅ Calculadora MAV PySide6 inicializada")
|
|
|
|
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)
|
|
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
|
|
|
|
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):
|
|
line = line.strip()
|
|
|
|
if not line or line.startswith('#'):
|
|
# Línea vacía o comentario
|
|
output_lines.append("")
|
|
if line.startswith('#'):
|
|
# Añadir comentario al panel LaTeX
|
|
comment_text = line[1:].strip()
|
|
if comment_text:
|
|
self.latex_panel.add_equation("comment", comment_text)
|
|
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:
|
|
output_lines.append(output_data[0][1]) # Tomar el texto 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 = []
|
|
|
|
# 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 in enumerate(output_lines):
|
|
if i > 0:
|
|
self.output_text.append("") # Nueva línea
|
|
|
|
# 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 _add_to_latex_panel_if_applicable(self, result: EvaluationResult):
|
|
"""Añade resultado al panel LaTeX - USANDO ESTRUCTURA ORIGINAL"""
|
|
if not result.success:
|
|
return
|
|
|
|
try:
|
|
# Determinar tipo de ecuación basado en flags del result
|
|
if result.is_assignment:
|
|
equation_type = "assignment"
|
|
elif result.is_equation:
|
|
equation_type = "equation"
|
|
else:
|
|
equation_type = "symbolic"
|
|
|
|
# Generar LaTeX del objeto resultado actual
|
|
if result.actual_result_object is not None:
|
|
latex_content = self._sympy_to_latex(result.actual_result_object)
|
|
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 - COMO EN ORIGINAL"""
|
|
try:
|
|
if hasattr(sympy_obj, 'latex'):
|
|
return sympy_obj.latex()
|
|
elif hasattr(sympy, 'latex'):
|
|
return sympy.latex(sympy_obj)
|
|
else:
|
|
return str(sympy_obj)
|
|
except Exception as e:
|
|
self.logger.error(f"Error convirtiendo a LaTeX: {e}")
|
|
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"""
|
|
try:
|
|
settings = {
|
|
"window_geometry": f"{self.width()}x{self.height()}",
|
|
"debug_mode": self.debug,
|
|
"latex_panel_visible": self.latex_panel_visible
|
|
}
|
|
|
|
with open(self.SETTINGS_FILE, 'w', encoding='utf-8') as f:
|
|
json.dump(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
|
|
|
|
# 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 modeless
|
|
self._autocomplete_popup = QWidget(self, Qt.Popup | Qt.FramelessWindowHint)
|
|
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
|
|
self._autocomplete_listbox = QListWidget()
|
|
self._autocomplete_listbox.setMaximumHeight(150)
|
|
|
|
# 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"""
|
|
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)
|
|
|
|
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 más discreto
|
|
self._autocomplete_popup = QWidget(self, Qt.Popup | Qt.FramelessWindowHint)
|
|
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
|
|
self._autocomplete_listbox = QListWidget()
|
|
self._autocomplete_listbox.setMaximumHeight(120)
|
|
|
|
# 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() |