""" 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 """
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() # ========== 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()