Calc/app/gui_autocomplete.py

677 lines
28 KiB
Python

"""
Sistema de Autocompletado para la Calculadora MAV CAS Híbrida
"""
import time
import re
from typing import List, Tuple, Optional
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QTextCursor
import logging
from .gui_widgets import AutocompletePopup
from .sympy_Helper import SympyTools as SympyHelper
from .type_registry import get_registered_base_context
class AutocompleteManager:
"""Gestor del sistema de autocompletado"""
def __init__(self, parent_app):
self.parent_app = parent_app
self.logger = logging.getLogger(__name__)
# Variables de autocompletado
self._autocomplete_popup = None
self._autocomplete_active = False
self._autocomplete_suggestions = []
self._autocomplete_filter_text = ""
self._autocomplete_trigger_pos = None
self._popup_disabled_until_next_dot = False
self._variable_popup_active = False
self._last_navigation_time = 0
self._last_input_change = 0
self._current_suggestions = []
self._is_global_popup = False
# Timers
self._variable_popup_timer = QTimer()
self._variable_popup_timer.setSingleShot(True)
self._variable_popup_timer.timeout.connect(self._show_variable_autocomplete_improved)
def handle_key_press(self, event) -> bool:
"""Maneja eventos de teclado para autocompletado - retorna True si manejó el evento"""
# Navegación en popup
if self._autocomplete_active or self._variable_popup_active:
if event.key() == Qt.Key_Up:
self._handle_arrow_key(-1)
return True
elif event.key() == Qt.Key_Down:
self._handle_arrow_key(1)
return True
elif event.key() == Qt.Key_Tab:
self._handle_tab_key()
return True
elif event.key() == Qt.Key_Escape:
self._handle_escape_key()
return True
elif event.key() in [Qt.Key_Return, Qt.Key_Enter]:
self._select_autocomplete()
return True
# Detectar backspace para filtrar autocompletado o cerrar popup
if event.key() == Qt.Key_Backspace:
if self._autocomplete_active:
# Filtrar dinámicamente al eliminar caracteres
QTimer.singleShot(1, self._filter_autocomplete)
QTimer.singleShot(1, self._check_dot_removal)
else:
# Activar autocompletado de variables si no está activo
QTimer.singleShot(50, self._schedule_variable_autocomplete_improved)
# Procesar autocompletado después de insertar carácter
if event.text() and not event.modifiers() & Qt.ControlModifier:
# Guardar datos del evento para evitar que se elimine el objeto
event_text = event.text()
event_key = event.key()
QTimer.singleShot(10, lambda: self._on_key_release_deferred(event_text, event_key))
return False
def _on_key_release_deferred(self, event_text: str, event_key: int):
"""Maneja eventos después de insertar carácter usando datos guardados"""
# Cancelar timer de variables
self._variable_popup_timer.stop()
# Verificar si acabamos de navegar
just_navigated = (time.time() - self._last_navigation_time) < 0.1
# Caracteres que cierran el autocompletado
closing_chars = [' ', '+', '-', '*', '/', '(', ')', '=', ',', ';', '>', '<', '!']
# Cerrar autocompletado con símbolos y espacio
if self._autocomplete_active and event_text in closing_chars:
self._close_autocomplete_popup()
return
# Manejar autocompletado con punto
if event_text == '.' and not self._popup_disabled_until_next_dot:
if self._variable_popup_active:
self._close_autocomplete_popup()
self._handle_dot_autocomplete()
# Filtrar autocompletado si está activo (incluye caracteres alfanuméricos y backspace procesado)
elif self._autocomplete_active and not just_navigated:
self._filter_autocomplete()
# Marcar tiempo del último cambio
if event_text:
self._last_input_change = time.time()
# Programar autocompletado de variables - también cuando se eliminen caracteres
if not self._autocomplete_active and not self._popup_disabled_until_next_dot:
self._schedule_variable_autocomplete_improved()
elif self._variable_popup_active and not just_navigated:
# Si el popup de variables está activo, regenerar dinámicamente
QTimer.singleShot(50, self._regenerate_variable_autocomplete)
def _handle_dot_autocomplete(self):
"""Maneja el autocompletado cuando se escribe un punto"""
self._close_autocomplete_popup()
cursor = self.parent_app.input_text.textCursor()
cursor_pos = cursor.position()
# Obtener línea actual
cursor.select(QTextCursor.LineUnderCursor)
line_text = cursor.selectedText()
# Calcular posición del punto en la línea
line_start = cursor.selectionStart()
dot_pos_in_line = cursor_pos - line_start - 1
if dot_pos_in_line < 0:
return
# Guardar posición del trigger
self._autocomplete_trigger_pos = cursor_pos
self._autocomplete_filter_text = ""
# Texto antes del punto
text_before_dot = line_text[:dot_pos_in_line].strip()
# Determinar si es popup GLOBAL
if not text_before_dot:
self.logger.debug("Dot en línea vacía. Ofreciendo sugerencias globales.")
suggestions = self._get_global_suggestions()
if suggestions:
self._show_autocomplete_popup(suggestions, is_global_popup=True)
return
# Es popup de OBJETO
obj_expr_str = self._extract_object_expression(text_before_dot)
if not obj_expr_str:
return
self.logger.debug(f"Autocompletado para objeto: '{obj_expr_str}'")
# Caso especial para sympy
if obj_expr_str == "sympy":
methods = SympyHelper.PopupFunctionList()
if methods:
self._show_autocomplete_popup(methods, is_global_popup=False)
return
# Preprocesar con bracket parser si es necesario
if '[' in obj_expr_str:
obj_expr_str = self.parent_app.engine.parser._transform_brackets(obj_expr_str)
# Evaluar la expresión del objeto
eval_context = self.parent_app.engine._get_full_context()
try:
obj = eval(obj_expr_str, eval_context)
self.logger.debug(f"Objeto evaluado: {type(obj)}")
# Mostrar métodos del objeto
if hasattr(obj, 'PopupFunctionList'):
methods = obj.PopupFunctionList()
if methods:
self._show_autocomplete_popup(methods, is_global_popup=False)
except Exception as e:
self.logger.debug(f"Error evaluando objeto '{obj_expr_str}': {e}")
def _extract_object_expression(self, text: str) -> str:
"""Extrae la expresión del objeto del texto antes del punto"""
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, text)
if match:
return match.group(1).replace(" ", "")
# Fallback
if re.match(r"^[a-zA-Z_0-9\(\)\[\]\.\"\'\+\-\*\/ ]*$", text) and \
not text.endswith(("+", "-", "*", "/", "(", ",")):
return text
return ""
def _get_global_suggestions(self) -> List[Tuple[str, str]]:
"""Obtiene sugerencias globales para autocompletado"""
suggestions = []
try:
# Obtener contexto dinámico
dynamic_context = get_registered_base_context()
for name, class_or_func in dynamic_context.items():
if name[0].isupper(): # Prioritizar capitalizados
hint = f"Tipo o función: {name}"
if hasattr(class_or_func, '__doc__') and class_or_func.__doc__:
first_line = class_or_func.__doc__.strip().split('\n')[0]
hint = f"{name} - {first_line}"
suggestions.append((name, hint))
# Añadir funciones SymPy
sympy_functions = SympyHelper.PopupFunctionList()
if sympy_functions:
current_names = {s[0] for s in suggestions}
for fname, fhint in sympy_functions:
if fname not in current_names:
suggestions.append((fname, fhint))
# Añadir variables del contexto actual
try:
context = self.parent_app.engine._get_full_context()
current_names = {s[0] for s in suggestions}
for name, value in context.items():
if (not name.startswith('_') and
not callable(value) and
name not in ['sympy', 'math', 'numpy', 'plt', 'builtins'] and
name not in current_names):
# Descripción del valor
value_str = str(value)
if len(value_str) > 20:
value_str = value_str[:17] + "..."
suggestions.append((name, f"Variable = {value_str}"))
except Exception:
pass
except Exception as e:
self.logger.debug(f"Error obteniendo sugerencias globales: {e}")
suggestions.sort(key=lambda x: x[0])
return suggestions
def _schedule_variable_autocomplete_improved(self):
"""Programa el autocompletado de variables"""
if self._autocomplete_active or self._popup_disabled_until_next_dot:
return
# Verificar que estemos escribiendo
cursor = self.parent_app.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 _regenerate_variable_autocomplete(self):
"""Regenera dinámicamente el autocompletado de variables"""
if not self._variable_popup_active:
return
# Obtener la palabra actual bajo el cursor
cursor = self.parent_app.input_text.textCursor()
cursor.select(QTextCursor.WordUnderCursor)
filter_text = cursor.selectedText().lower()
# Obtener variables del contexto actual
try:
context = self.parent_app.engine._get_full_context()
variables = []
# Filtrar variables
for name, value in context.items():
if (not name.startswith('_') and
not callable(value) and
name not in ['sympy', 'math', 'numpy', 'plt', 'builtins']):
# Descripción del valor
value_str = str(value)
if len(value_str) > 20:
value_str = value_str[:17] + "..."
variables.append((name, f"= {value_str}"))
if variables:
variables.sort(key=lambda x: x[0])
# Filtrar por texto actual si existe
if filter_text:
filtered_vars = [
(name, value) for name, value in variables
if name.lower().startswith(filter_text) and name.lower() != filter_text
]
else:
filtered_vars = variables
if filtered_vars:
# Actualizar sugerencias y popup
self._current_suggestions = variables
if self._autocomplete_popup:
self._autocomplete_popup.set_suggestions(filtered_vars)
self._autocomplete_popup.adjust_size()
else:
self._close_autocomplete_popup()
else:
self._close_autocomplete_popup()
except Exception as e:
self.logger.debug(f"Error regenerando variables: {e}")
def _show_variable_autocomplete_improved(self):
"""Muestra autocompletado de variables disponibles"""
if self._autocomplete_active or self._variable_popup_active:
return
# Verificar línea actual
cursor = self.parent_app.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.parent_app.engine._get_full_context()
variables = []
# Filtrar variables
for name, value in context.items():
if (not name.startswith('_') and
not callable(value) and
name not in ['sympy', 'math', 'numpy', 'plt', 'builtins']):
# Descripción del valor
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])
# Filtrar por palabra actual
words = current_line.split()
if words:
last_word = words[-1]
filtered_vars = [
(name, value) for name, value in variables
if name.lower().startswith(last_word.lower()) and name != last_word
]
if filtered_vars:
self._show_variable_popup(filtered_vars)
except Exception as e:
self.logger.debug(f"Error obteniendo variables: {e}")
def _show_autocomplete_popup(self, suggestions: List[Tuple[str, str]], is_global_popup: bool = False):
"""Muestra popup de autocompletado"""
if not suggestions:
return
self._close_autocomplete_popup()
# Guardar estado
self._current_suggestions = suggestions.copy()
self._is_global_popup = is_global_popup
self._autocomplete_active = True
# Crear popup
self._autocomplete_popup = AutocompletePopup(self.parent_app)
self._autocomplete_popup.set_suggestions(suggestions)
self._autocomplete_popup.item_selected.connect(self._on_autocomplete_selected)
# Posicionar
cursor_rect = self.parent_app.input_text.cursorRect()
global_pos = self.parent_app.input_text.mapToGlobal(cursor_rect.bottomLeft())
self._autocomplete_popup.move(global_pos)
self._autocomplete_popup.adjust_size()
self._autocomplete_popup.show()
def _show_variable_popup(self, variables: List[Tuple[str, str]]):
"""Muestra popup de variables"""
self._variable_popup_active = True
self._autocomplete_active = True # También debe estar activo para el filtrado
# Las variables ya vienen en el formato correcto (nombre, descripción)
self._show_autocomplete_popup(variables, is_global_popup=False)
def _filter_autocomplete(self):
"""Filtra las sugerencias del autocompletado dinámicamente"""
if not self._autocomplete_active:
return
cursor = self.parent_app.input_text.textCursor()
current_pos = cursor.position()
# Para popup de variables (filtrar por texto parcial)
if self._variable_popup_active:
# Obtener la palabra actual bajo el cursor
cursor.select(QTextCursor.WordUnderCursor)
filter_text = cursor.selectedText().lower()
self._autocomplete_filter_text = filter_text
# Si no hay texto que filtrar, regenerar popup con todas las variables
if not filter_text:
# Regenerar popup dinámicamente con variables actuales
try:
context = self.parent_app.engine._get_full_context()
variables = []
# Filtrar variables
for name, value in context.items():
if (not name.startswith('_') and
not callable(value) and
name not in ['sympy', 'math', 'numpy', 'plt', 'builtins']):
# Descripción del valor
value_str = str(value)
if len(value_str) > 20:
value_str = value_str[:17] + "..."
variables.append((name, f"= {value_str}"))
if variables:
variables.sort(key=lambda x: x[0])
self._current_suggestions = variables
if self._autocomplete_popup:
self._autocomplete_popup.set_suggestions(variables)
self._autocomplete_popup.adjust_size()
else:
self._close_autocomplete_popup()
except Exception:
self._close_autocomplete_popup()
return
# Filtrar sugerencias de variables existentes + regenerar con contexto actual
try:
context = self.parent_app.engine._get_full_context()
all_variables = []
# Obtener todas las variables actuales
for name, value in context.items():
if (not name.startswith('_') and
not callable(value) and
name not in ['sympy', 'math', 'numpy', 'plt', 'builtins']):
# Descripción del valor
value_str = str(value)
if len(value_str) > 20:
value_str = value_str[:17] + "..."
all_variables.append((name, f"= {value_str}"))
# Filtrar por el texto parcial
filtered = []
for name, hint in all_variables:
if name.lower().startswith(filter_text):
filtered.append((name, hint))
# Actualizar sugerencias actuales
self._current_suggestions = all_variables
if filtered and self._autocomplete_popup:
self._autocomplete_popup.set_suggestions(filtered)
self._autocomplete_popup.adjust_size()
else:
self._close_autocomplete_popup()
except Exception:
# Fallback al método anterior
filtered = []
for name, hint in self._current_suggestions:
if name.lower().startswith(filter_text):
filtered.append((name, hint))
if filtered and self._autocomplete_popup:
self._autocomplete_popup.set_suggestions(filtered)
self._autocomplete_popup.adjust_size()
else:
self._close_autocomplete_popup()
# Para popup de métodos (filtrar después del punto)
elif self._autocomplete_trigger_pos:
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:
# Si el cursor está antes del punto trigger, cerrar popup
self._close_autocomplete_popup()
return
# Filtrar sugerencias de métodos
filtered = []
for name, hint in self._current_suggestions:
if name.lower().startswith(self._autocomplete_filter_text):
filtered.append((name, hint))
if filtered and self._autocomplete_popup:
self._autocomplete_popup.set_suggestions(filtered)
self._autocomplete_popup.adjust_size()
else:
self._close_autocomplete_popup()
def _handle_arrow_key(self, direction: int):
"""Maneja navegación con flechas en el popup"""
if self._autocomplete_popup:
self._autocomplete_popup.navigate(direction)
self._last_navigation_time = time.time()
def _handle_tab_key(self):
"""Maneja tecla TAB para seleccionar"""
self._select_autocomplete()
def _handle_escape_key(self):
"""Maneja tecla ESC para cerrar popup"""
self._close_autocomplete_popup()
if self._autocomplete_active:
self._popup_disabled_until_next_dot = True
def _select_autocomplete(self):
"""Selecciona el item actual del autocompletado"""
if not self._autocomplete_popup:
return
selected_text = self._autocomplete_popup.get_selected_text()
if selected_text:
self._insert_autocomplete_text(selected_text)
self._close_autocomplete_popup()
def _on_autocomplete_selected(self, text: str):
"""Callback cuando se selecciona un item del popup"""
self._insert_autocomplete_text(text)
self._close_autocomplete_popup()
def _insert_autocomplete_text(self, text: str):
"""Inserta el texto seleccionado del autocompletado"""
cursor = self.parent_app.input_text.textCursor()
# Para popup de variables
if self._variable_popup_active:
# Obtener el texto parcial ya escrito
current_pos = cursor.position()
cursor.select(QTextCursor.WordUnderCursor)
partial_text = cursor.selectedText()
# Verificar si hay texto parcial que ya coincide con el inicio de la variable
if partial_text and text.lower().startswith(partial_text.lower()):
# Reemplazar completamente la palabra seleccionada con la variable completa
cursor.insertText(text)
elif partial_text:
# Si hay texto parcial pero no coincide, reemplazar completamente
cursor.insertText(text)
else:
# Si no hay texto parcial, insertar directamente
cursor.insertText(text)
return
# Para popup global (después de punto solo)
if self._is_global_popup:
# Eliminar el punto y añadir función/variable
if self._autocomplete_trigger_pos is not None:
cursor.setPosition(self._autocomplete_trigger_pos - 1)
else:
cursor.setPosition(0)
if self._autocomplete_trigger_pos is not None:
cursor.setPosition(self._autocomplete_trigger_pos, QTextCursor.KeepAnchor)
# Verificar si es una función o variable para decidir si agregar paréntesis
try:
context = self.parent_app.engine._get_full_context()
if text in context:
obj = context[text]
if callable(obj):
# Es una función, agregar paréntesis
cursor.insertText(text + "()")
cursor.setPosition((self._autocomplete_trigger_pos or 0) - 1 + len(text) + 1)
else:
# Es una variable, no agregar paréntesis
cursor.insertText(text)
cursor.setPosition((self._autocomplete_trigger_pos or 0) - 1 + len(text))
else:
# Por defecto, asumir que es función si no está en contexto
cursor.insertText(text + "()")
cursor.setPosition((self._autocomplete_trigger_pos or 0) - 1 + len(text) + 1)
except Exception:
# Fallback: agregar paréntesis por defecto
cursor.insertText(text + "()")
cursor.setPosition((self._autocomplete_trigger_pos or 0) - 1 + len(text) + 1)
self.parent_app.input_text.setTextCursor(cursor)
else:
# Para métodos de objeto - considerar texto ya escrito después del punto
if self._autocomplete_filter_text:
# Calcular la posición correcta del texto ya escrito
current_pos = cursor.position()
filter_len = len(self._autocomplete_filter_text)
# Seleccionar el texto filtrado para reemplazarlo
cursor.setPosition(current_pos - filter_len)
cursor.setPosition(current_pos, QTextCursor.KeepAnchor)
cursor.removeSelectedText()
# Insertar el texto completo del método
cursor.insertText(text + "()")
cursor.setPosition(cursor.position() - 1)
else:
# Si no hay texto filtrado, insertar normalmente
cursor.insertText(text + "()")
cursor.setPosition(cursor.position() - 1)
self.parent_app.input_text.setTextCursor(cursor)
def _check_dot_removal(self):
"""Verifica si se borró el punto que activó el autocompletado"""
if not self._autocomplete_active or not self._autocomplete_trigger_pos:
return
cursor = self.parent_app.input_text.textCursor()
current_pos = cursor.position()
# Si el cursor está antes de la posición del trigger, el punto fue eliminado
if current_pos < self._autocomplete_trigger_pos:
self._close_autocomplete_popup()
return
# Verificar si todavía existe el punto en la posición trigger
if self._autocomplete_trigger_pos > 0:
cursor.setPosition(self._autocomplete_trigger_pos - 1)
cursor.setPosition(self._autocomplete_trigger_pos, QTextCursor.KeepAnchor)
if cursor.selectedText() != '.':
self._close_autocomplete_popup()
def _close_autocomplete_popup(self):
"""Cierra el popup de autocompletado"""
if self._autocomplete_popup:
self._autocomplete_popup.close()
self._autocomplete_popup = None
# Resetear estado
self._autocomplete_active = False
self._variable_popup_active = False
self._autocomplete_trigger_pos = None
self._autocomplete_filter_text = ""
self._current_suggestions = []
def stop_timers(self):
"""Detiene todos los timers del autocompletado"""
self._variable_popup_timer.stop()
def reset_state(self):
"""Resetea el estado del autocompletado"""
self._close_autocomplete_popup()
self._popup_disabled_until_next_dot = False
def on_input_changed(self):
"""Método llamado cuando cambia el texto de entrada"""
# Marcar tiempo del cambio
self._last_input_change = time.time()
# Si hay autocompletado activo, filtrar dinámicamente
if self._autocomplete_active:
QTimer.singleShot(10, self._filter_autocomplete)
elif not self._popup_disabled_until_next_dot:
# Programar autocompletado de variables
self._schedule_variable_autocomplete_improved()