""" 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 ) 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 """ MathJax Panel
""" 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) # ========== 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()) # Widget central central_widget = QWidget() self.setCentralWidget(central_widget) # Layout principal horizontal main_layout = QHBoxLayout(central_widget) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(1) # ========== 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 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() # ========== LAYOUT DE 3 PANELES ========== # Paneles 1 y 2 tienen mismo ancho, panel 3 es más estrecho main_layout.addWidget(self.input_text, 1) main_layout.addWidget(self.output_text, 1) main_layout.addWidget(self.latex_panel, 0) # ========== 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 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 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; } """ 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", """

Calculadora MAV - CAS Híbrido

Versión PySide6 con diseño minimalista

Sistema algebraico computacional híbrido con 3 paneles:

Motor algebraico: SymPy con tipos personalizados

""" ) def load_settings(self) -> Dict[str, Any]: """Carga configuración desde archivo""" 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() 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()