1263 lines
61 KiB
Python
1263 lines
61 KiB
Python
"""
|
|
Calculadora MAV - Archivo principal
|
|
"""
|
|
import tkinter as tk
|
|
from tkinter import scrolledtext, messagebox, Menu, filedialog
|
|
import ast
|
|
import json
|
|
import os
|
|
import math
|
|
import sympy
|
|
import tkinter.font as tkFont
|
|
|
|
# Importar las clases desde los módulos separados
|
|
from base_types import BaseCalcType
|
|
from hex_type import Hex
|
|
from bin_type import Bin
|
|
from ip4_type import IP4
|
|
from date_type import Date
|
|
from dec_type import Dec
|
|
from chr_type import Chr # Importar la nueva clase Chr
|
|
from solve_type import solve
|
|
from line_analyzer import LineResultCapture
|
|
from sympy_integration import SymPyIntegration
|
|
|
|
# Para la ayuda en HTML
|
|
MARKDOWN_AVAILABLE = False
|
|
HTML_VIEWER_TYPE = None
|
|
|
|
try:
|
|
import markdown
|
|
MARKDOWN_AVAILABLE = True
|
|
except ImportError:
|
|
# markdown not available, MARKDOWN_AVAILABLE remains False
|
|
pass # Se mostrará una advertencia más adelante si es necesario
|
|
|
|
# Intentar importar visores HTML
|
|
try:
|
|
import tkinterweb
|
|
HTML_VIEWER_TYPE = "tkinterweb"
|
|
except ImportError:
|
|
try:
|
|
from tkhtmlview import HTMLScrolledText
|
|
HTML_VIEWER_TYPE = "tkhtmlview"
|
|
except ImportError:
|
|
HTML_VIEWER_TYPE = None # Ambos visores fallaron
|
|
|
|
if not MARKDOWN_AVAILABLE:
|
|
print("Advertencia: La librería 'markdown' no está instalada. La ayuda podría mostrarse en texto plano o con formato limitado.")
|
|
if MARKDOWN_AVAILABLE and HTML_VIEWER_TYPE is None: # Si markdown está pero no hay visor
|
|
print("Advertencia: 'markdown' está disponible, pero no se encontró un visor HTML (tkinterweb/tkhtmlview). La ayuda se mostrará en texto plano.")
|
|
|
|
class CalculatorApp:
|
|
SETTINGS_FILE = "calc_settings.json"
|
|
CUSTOM_CLASSES_WITH_HELPERS = [Hex, Bin, IP4, Date, Dec, solve, Chr] # Agregar Chr
|
|
OPERATOR_HELP_MESSAGES = {
|
|
"^": "Operador XOR a nivel de bits (ej: 5 ^ 3) o diferencia simétrica para conjuntos (ej: {1,2} ^ {2,3}). Requiere un segundo operando.",
|
|
"+": "Operador de suma. Requiere un segundo operando.",
|
|
"-": "Operador de resta. Requiere un segundo operando.",
|
|
"*": "Operador de multiplicación. Requiere un segundo operando.",
|
|
"/": "Operador de división. Requiere un segundo operando.",
|
|
"**": "Operador de potencia. Requiere un segundo operando.",
|
|
"//": "Operador de división entera. Requiere un segundo operando.",
|
|
"%": "Operador de módulo. Requiere un segundo operando.",
|
|
"&": "Operador AND a nivel de bits. Requiere un segundo operando.",
|
|
"|": "Operador OR a nivel de bits. Requiere un segundo operando.",
|
|
}
|
|
|
|
def __init__(self, root):
|
|
self.root = root
|
|
self.root.title("Calculadora MAV")
|
|
|
|
# Cargar y establecer el ícono de la aplicación
|
|
try:
|
|
self.app_icon = tk.PhotoImage(file="icon.png")
|
|
self.root.iconphoto(True, self.app_icon)
|
|
except tk.TclError:
|
|
print("Advertencia: No se pudo cargar o establecer 'icon.png' como ícono de la aplicación.")
|
|
|
|
self.settings = self._load_app_settings()
|
|
self.root.geometry(self.settings.get("window_geometry", "900x600"))
|
|
self.root.configure(bg="#2b2b2b")
|
|
|
|
# Integración con SymPy
|
|
self.sympy_integration = SymPyIntegration()
|
|
|
|
# Funciones y constantes matemáticas
|
|
math_functions = {
|
|
"pi": math.pi, "e": math.e,
|
|
"sin": math.sin, "cos": math.cos, "tan": math.tan,
|
|
"asin": math.asin, "acos": math.acos, "atan": math.atan,
|
|
"degrees": math.degrees, "radians": math.radians,
|
|
"exp": math.exp, "log": math.log, "log10": math.log10, "log2": math.log2,
|
|
"sqrt": math.sqrt,
|
|
}
|
|
|
|
self.calc_context_base = {
|
|
"Hex": Hex, "Bin": Bin, "IP4": IP4, "Date": Date, "Dec": Dec, "Chr": Chr,
|
|
"solve": solve,
|
|
"hex": Hex, "bin": Bin, "ip4": IP4, "date": Date, "dec": Dec, "chr": Chr, # Alias en minúscula
|
|
**math_functions,
|
|
"__builtins__": {
|
|
"print": self._custom_print,
|
|
"abs": abs, "round": round, "len": len, "str": str, "int": int, "float": float,
|
|
"list": list, "dict": dict, "tuple": tuple, "pow": pow, "sum": sum,
|
|
"SyntaxError": SyntaxError,
|
|
"min": min, "max": max, "range": range, "enumerate": enumerate, "zip": zip,
|
|
"Hex": Hex, "Bin": Bin, "IP4": IP4, "Date": Date, "Dec": Dec, "Chr": Chr,
|
|
"solve": solve,
|
|
"hex": Hex, "bin": Bin, "ip4": IP4, "date": Date, "dec": Dec, "chr": Chr, # Alias en minúscula
|
|
**math_functions,
|
|
},
|
|
}
|
|
|
|
self.output_buffer = []
|
|
self._debounce_job = None
|
|
self._syncing_yview = False
|
|
self._cached_input_font = None # Para cachear el objeto de fuente del panel de entrada
|
|
self.create_widgets()
|
|
self.load_persistent_input()
|
|
|
|
def _load_app_settings(self):
|
|
if os.path.exists(self.SETTINGS_FILE):
|
|
try:
|
|
with open(self.SETTINGS_FILE, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except (IOError, json.JSONDecodeError):
|
|
return {}
|
|
return {}
|
|
|
|
def _save_app_settings(self):
|
|
self.settings["window_geometry"] = self.root.winfo_geometry()
|
|
if hasattr(self, "paned_window"):
|
|
try:
|
|
sash_x_pos = self.paned_window.sash_coord(0)[0]
|
|
self.settings["sash_pos_x"] = sash_x_pos
|
|
except tk.TclError:
|
|
pass
|
|
try:
|
|
with open(self.SETTINGS_FILE, "w", encoding="utf-8") as f:
|
|
json.dump(self.settings, f, indent=4)
|
|
except IOError:
|
|
messagebox.showwarning("Error", "No se pudieron guardar los ajustes de la interfaz.")
|
|
|
|
def create_widgets(self):
|
|
main_frame = tk.Frame(self.root, bg="#2b2b2b", bd=0)
|
|
main_frame.pack(fill=tk.BOTH, expand=True, padx=0, pady=0)
|
|
|
|
self.paned_window = tk.PanedWindow(
|
|
main_frame, orient=tk.HORIZONTAL, bg="#2b2b2b",
|
|
sashrelief=tk.FLAT, sashwidth=4, bd=0,
|
|
showhandle=False, opaqueresize=True,
|
|
)
|
|
self.paned_window.pack(fill=tk.BOTH, expand=True, padx=0, pady=0)
|
|
|
|
initial_input_width = self.settings.get("sash_pos_x", 400)
|
|
|
|
self.input_text = scrolledtext.ScrolledText(
|
|
self.paned_window, font=("Consolas", 11), bg="#1e1e1e", fg="#d4d4d4",
|
|
insertbackground="#ffffff", selectbackground="#264f78", undo=True,
|
|
wrap=tk.NONE, borderwidth=0, highlightthickness=0, relief=tk.FLAT,
|
|
)
|
|
self.paned_window.add(self.input_text, width=initial_input_width, stretch="always", minsize=150)
|
|
|
|
self.output_text = scrolledtext.ScrolledText(
|
|
self.paned_window, font=("Consolas", 11), bg="#0f0f0f", fg="#00ff00",
|
|
state="disabled", wrap=tk.NONE, borderwidth=0,
|
|
highlightthickness=0, relief=tk.FLAT,
|
|
)
|
|
self.paned_window.add(self.output_text, stretch="always", minsize=150)
|
|
|
|
self.input_text.bind("<KeyRelease>", self.on_key_release)
|
|
self.input_text.bind("<Button-3>", lambda e: self._show_context_menu(e, self.input_text, "input"))
|
|
self.output_text.bind("<Button-3>", lambda e: self._show_context_menu(e, self.output_text, "output"))
|
|
|
|
self.setup_scroll_sync()
|
|
self.setup_output_tags()
|
|
|
|
def _yscroll_input_command(self, *args):
|
|
self.input_text.vbar.set(*args)
|
|
if not self._syncing_yview:
|
|
self._syncing_yview = True
|
|
self.output_text.yview_moveto(args[0])
|
|
self._syncing_yview = False
|
|
|
|
def _yscroll_output_command(self, *args):
|
|
self.output_text.vbar.set(*args)
|
|
if not self._syncing_yview:
|
|
self._syncing_yview = True
|
|
self.input_text.yview_moveto(args[0])
|
|
self._syncing_yview = False
|
|
|
|
def _unified_mouse_wheel(self, event):
|
|
if self._syncing_yview:
|
|
return "break"
|
|
if hasattr(event, "widget") and event.widget:
|
|
event.widget.yview_scroll(int(-1 * (event.delta / 120)), "units")
|
|
return "break"
|
|
|
|
def setup_scroll_sync(self):
|
|
self.input_text.config(yscrollcommand=self._yscroll_input_command)
|
|
self.output_text.config(yscrollcommand=self._yscroll_output_command)
|
|
self.input_text.bind("<MouseWheel>", self._unified_mouse_wheel)
|
|
self.output_text.bind("<MouseWheel>", self._unified_mouse_wheel)
|
|
|
|
def _show_context_menu(self, event, widget_ref, widget_type):
|
|
context_menu = Menu(
|
|
self.root, tearoff=0, bg="#3c3c3c", fg="white",
|
|
activebackground="#007acc", activeforeground="white",
|
|
relief=tk.FLAT, bd=1,
|
|
)
|
|
|
|
if widget_type == "input":
|
|
context_menu.add_command(label="Cortar", command=lambda: widget_ref.event_generate("<<Cut>>"))
|
|
context_menu.add_command(label="Copiar", command=lambda: widget_ref.event_generate("<<Copy>>"))
|
|
context_menu.add_command(label="Pegar", command=lambda: widget_ref.event_generate("<<Paste>>"))
|
|
context_menu.add_separator(background="#555555")
|
|
context_menu.add_command(label="Borrar Todo", command=self._clear_input_text)
|
|
context_menu.add_separator(background="#555555")
|
|
context_menu.add_command(label="Guardar como...", command=self._save_input_as)
|
|
context_menu.add_command(label="Cargar archivo...", command=self._load_input_from_file)
|
|
elif widget_type == "output":
|
|
context_menu.add_command(label="Copiar Todo", command=self._copy_all_output)
|
|
context_menu.add_command(label="Borrar Salida", command=self._clear_output_text)
|
|
|
|
context_menu.add_separator(background="#555555")
|
|
context_menu.add_command(label="Ayuda", command=self._show_help_window)
|
|
|
|
try:
|
|
context_menu.tk_popup(event.x_root, event.y_root)
|
|
finally:
|
|
context_menu.grab_release()
|
|
|
|
def _clear_output_text(self):
|
|
self.output_text.config(state=tk.NORMAL)
|
|
self.output_text.delete("1.0", tk.END)
|
|
self.output_text.config(state=tk.DISABLED)
|
|
|
|
def _copy_all_output(self):
|
|
self.output_text.config(state=tk.NORMAL)
|
|
content = self.output_text.get("1.0", tk.END).strip()
|
|
self.output_text.config(state=tk.DISABLED)
|
|
if content:
|
|
self.root.clipboard_clear()
|
|
self.root.clipboard_append(content)
|
|
|
|
def _show_help_window(self):
|
|
help_win = tk.Toplevel(self.root)
|
|
help_win.title("Ayuda - Calculadora")
|
|
help_win.geometry("750x600")
|
|
# Usar un fondo oscuro consistente para la ventana de ayuda
|
|
help_win.configure(bg="#1e1e1e") # Coincide con el fondo del HTMLScrolledText y CSS
|
|
help_win.transient(self.root)
|
|
|
|
readme_content = self._get_help_content()
|
|
|
|
if MARKDOWN_AVAILABLE and HTML_VIEWER_TYPE:
|
|
try:
|
|
# CSS para un tema oscuro, consistente con la UI de la calculadora
|
|
# Simplificado para no usar selectores 'body' o 'html'
|
|
# y asumiendo que los estilos base se aplican al widget.
|
|
dark_theme_css = """
|
|
<style>
|
|
h1, h2, h3, h4, h5, h6 {
|
|
color: #569cd6;
|
|
margin-top: 1.2em;
|
|
margin-bottom: 0.6em;
|
|
border-bottom: 1px solid #3a3a3a;
|
|
padding-bottom: 0.3em;
|
|
}
|
|
h1 { font-size: 1.8em; }
|
|
h2 { font-size: 1.5em; }
|
|
h3 { font-size: 1.3em; }
|
|
p {
|
|
line-height: 1.65;
|
|
margin-bottom: 0.8em;
|
|
}
|
|
a {
|
|
color: #4fc3f7;
|
|
text-decoration: none;
|
|
}
|
|
a:hover {
|
|
color: #80dfff;
|
|
text-decoration: underline;
|
|
}
|
|
code {
|
|
font-family: "Consolas", "Courier New", monospace;
|
|
background-color: #2d2d2d;
|
|
color: #ce9178;
|
|
padding: 3px 6px;
|
|
border-radius: 4px;
|
|
border: 1px solid #3c3c3c;
|
|
font-size: 0.95em;
|
|
}
|
|
pre > code {
|
|
background-color: transparent !important;
|
|
color: inherit !important;
|
|
padding: 0 !important;
|
|
border-radius: 0 !important;
|
|
border: none !important;
|
|
font-size: 1em !important;
|
|
}
|
|
ul, ol { padding-left: 25px; color: #c8c8c8; }
|
|
li { margin-bottom: 0.4em; }
|
|
hr { border: 0; height: 1px; background: #3a3a3a; margin: 1.5em 0; }
|
|
table { border-collapse: collapse; width: 90%; margin: 1em auto; }
|
|
th, td { border: 1px solid #4a4a4a; padding: 8px; text-align: left; }
|
|
th { background-color: #2d2d2d; color: #9cdcfe; }
|
|
</style>
|
|
"""
|
|
html_fragment = markdown.markdown(
|
|
readme_content, extensions=["fenced_code", "codehilite", "tables", "nl2br", "admonition"],
|
|
extension_configs={"codehilite": {"noclasses": True, "pygments_style": "monokai"}}
|
|
)
|
|
|
|
# Construir un documento HTML completo
|
|
content_for_viewer = f"""
|
|
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Ayuda de Calculadora</title>
|
|
{dark_theme_css}
|
|
</head>
|
|
<body style="background-color: #1e1e1e; color: #d4d4d4; font-family: 'Segoe UI', sans-serif; font-size: 10pt; margin:0; padding:12px;">
|
|
{html_fragment}
|
|
</body>
|
|
</html>
|
|
"""
|
|
if HTML_VIEWER_TYPE == "tkinterweb":
|
|
# tkinterweb.HtmlFrame no toma bg/fg directamente en constructor para el contenido
|
|
html_viewer = tkinterweb.HtmlFrame(help_win, messages_enabled=False)
|
|
html_viewer.load_html(content_for_viewer)
|
|
# El fondo del Frame en sí se puede configurar si es necesario,
|
|
# pero el body style debería controlarlo.
|
|
# html_viewer.configure(background="#1e1e1e")
|
|
elif HTML_VIEWER_TYPE == "tkhtmlview":
|
|
html_viewer = HTMLScrolledText(help_win)
|
|
html_viewer.configure(bg="#1e1e1e")
|
|
html_viewer.set_html(content_for_viewer)
|
|
|
|
html_viewer.pack(padx=0, pady=0, fill=tk.BOTH, expand=True) # El padding se maneja en el CSS
|
|
except Exception as e:
|
|
print(f"Error al renderizar Markdown a HTML: {e}") # Para depuración
|
|
# Fallback to text if HTML fails
|
|
self._show_text_help(help_win, readme_content)
|
|
else:
|
|
self._show_text_help(help_win, readme_content)
|
|
|
|
close_button = tk.Button(
|
|
help_win, text="Cerrar", command=help_win.destroy,
|
|
bg="#3c3c3c", fg="white", relief=tk.FLAT, padx=10,
|
|
)
|
|
close_button.pack(pady=(5, 10))
|
|
|
|
def _get_help_content(self):
|
|
"""Obtiene el contenido de ayuda desde readme.md o genera uno por defecto"""
|
|
try:
|
|
readme_path = "readme.md"
|
|
if os.path.exists(readme_path):
|
|
with open(readme_path, "r", encoding="utf-8") as f:
|
|
return f.read()
|
|
except IOError:
|
|
pass
|
|
|
|
# Contenido por defecto si no se encuentra readme.md
|
|
return """# Calculadora MAV - Ayuda
|
|
|
|
## Nuevas Características:
|
|
- **Ecuaciones algebraicas**: Usa comillas para definir ecuaciones: `"x + 10 = 15"`
|
|
- **Resolver ecuaciones**: Usa `solve(x)` para resolver una variable
|
|
- **Resolver múltiples variables**: `solve(x, y)` o `solve()` para todas
|
|
- **Contexto dinámico**: Las variables definidas después de solve se usan automáticamente
|
|
|
|
## Ejemplo de uso algebraico:
|
|
```
|
|
"x + 10 = 15"
|
|
"a*y - 5 = 20"
|
|
solve(x) # x = 5
|
|
solve(y) # y = 25/a
|
|
a = 3
|
|
y/25 # 1/3
|
|
```
|
|
|
|
## Clases disponibles:
|
|
- `Hex()`: Números hexadecimales
|
|
- `Bin()`: Números binarios
|
|
- `IP4()`: Direcciones IPv4
|
|
- `Date()`: Fechas
|
|
- `Dec()`: Decimales
|
|
|
|
## Funciones matemáticas:
|
|
- Constantes: `pi`, `e`
|
|
- Trigonométricas: `sin()`, `cos()`, `tan()`, `asin()`, `acos()`, `atan()`
|
|
- Conversión Angular: `degrees()`, `radians()`
|
|
- Exponenciales/Logarítmicas: `exp()`, `log()`, `log10()`, `log2()`, `sqrt()`
|
|
- Variable `last` para el último resultado
|
|
|
|
## Menú Contextual (clic derecho):
|
|
- En entrada: Cortar, Copiar, Pegar, Borrar Todo, Guardar como..., Cargar archivo...
|
|
- En salida: Copiar Todo, Borrar Salida
|
|
"""
|
|
|
|
def _show_text_help(self, help_win, content):
|
|
"""Muestra la ayuda en texto plano cuando markdown no está disponible"""
|
|
text_widget = scrolledtext.ScrolledText(
|
|
help_win, font=("Consolas", 10), bg="#1e1e1e", fg="#d4d4d4",
|
|
wrap=tk.WORD, borderwidth=0, highlightthickness=0
|
|
)
|
|
text_widget.insert("1.0", content)
|
|
text_widget.config(state="disabled")
|
|
text_widget.pack(padx=5, pady=5, fill=tk.BOTH, expand=True)
|
|
|
|
def setup_output_tags(self):
|
|
self.output_text.tag_configure("error", foreground="#ff6b6b", font=("Consolas", 11, "bold"))
|
|
self.output_text.tag_configure("result", foreground="#abdbe3")
|
|
self.output_text.tag_configure("hex", foreground="#f9a825")
|
|
self.output_text.tag_configure("bin", foreground="#4fc3f7")
|
|
self.output_text.tag_configure("ip", foreground="#fff176")
|
|
self.output_text.tag_configure("date", foreground="#ff8a80")
|
|
self.output_text.tag_configure("comment", foreground="#7f8c8d")
|
|
self.output_text.tag_configure("print_output", foreground="#b0bec5")
|
|
self.output_text.tag_configure("chr_type_tag", foreground="#80cbc4") # Teal-ish para Chr
|
|
self.output_text.tag_configure("equation", foreground="#c792ea")
|
|
self.output_text.tag_configure("type_hint", foreground="#6a6a6a") # Gris oscuro para el hint de tipo
|
|
self.output_text.tag_configure("symbolic", foreground="#82aaff")
|
|
|
|
def _custom_print(self, *args, **kwargs):
|
|
sep = kwargs.get("sep", " ")
|
|
output = sep.join(map(str, args))
|
|
self.output_buffer.append(("print", output))
|
|
|
|
|
|
def evaluate_all_lines(self):
|
|
input_content = self.input_text.get("1.0", tk.END)
|
|
if not input_content.strip():
|
|
self.set_output_text_with_colors([])
|
|
return
|
|
|
|
lines = input_content.splitlines()
|
|
output_data = []
|
|
|
|
# Limpiar ecuaciones de SymPy para re-evaluación completa
|
|
self.sympy_integration.clear()
|
|
|
|
# Crear un nuevo contexto para esta pasada de evaluación
|
|
current_pass_context = self.calc_context_base.copy()
|
|
current_pass_context["__builtins__"] = self.calc_context_base["__builtins__"].copy()
|
|
last_val_for_current_line = None
|
|
line_result_capture = LineResultCapture()
|
|
|
|
# Store the context after each line for potential use by autocomplete
|
|
# This is a simplification; a more robust system might be needed.
|
|
self._line_contexts_for_autocomplete = {}
|
|
|
|
for line_number, line_text_original in enumerate(lines, 1):
|
|
self.output_buffer = []
|
|
current_pass_context["last"] = last_val_for_current_line
|
|
current_line_outputs = []
|
|
comment_part = ""
|
|
code_to_evaluate = line_text_original
|
|
|
|
# Separar comentarios
|
|
if "#" in code_to_evaluate:
|
|
code_to_evaluate, comment_part = code_to_evaluate.split("#", 1)
|
|
comment_part = "#" + comment_part
|
|
code_to_evaluate_stripped = code_to_evaluate.strip()
|
|
|
|
# Reemplazar "var = ?" por "solve('var')"
|
|
if code_to_evaluate_stripped.endswith("=?"):
|
|
parts = code_to_evaluate_stripped.rsplit("=?", 1)
|
|
var_name_to_solve = parts[0].strip()
|
|
if var_name_to_solve:
|
|
# Escapar comillas dobles si estuvieran en var_name_to_solve (poco probable para nombres de var)
|
|
safe_var_name = var_name_to_solve.replace('"', '\\"')
|
|
code_to_evaluate_stripped = f'solve("{safe_var_name}")'
|
|
|
|
# Verificar si es solo el nombre de una clase (para mostrar ayuda)
|
|
showed_proactive_hint_for_class_name = False
|
|
if code_to_evaluate_stripped:
|
|
for cls in self.CUSTOM_CLASSES_WITH_HELPERS:
|
|
if code_to_evaluate_stripped.lower() == cls.__name__.lower():
|
|
hint = cls.Helper(code_to_evaluate_stripped)
|
|
if hint:
|
|
current_line_outputs.append(("comment", hint))
|
|
if comment_part:
|
|
current_line_outputs.append(("comment", " " + comment_part.strip()))
|
|
showed_proactive_hint_for_class_name = True
|
|
break
|
|
|
|
if showed_proactive_hint_for_class_name:
|
|
line_actual_object = last_val_for_current_line
|
|
output_data.append(current_line_outputs)
|
|
continue
|
|
|
|
# Línea vacía
|
|
if not code_to_evaluate_stripped:
|
|
if comment_part:
|
|
current_line_outputs.append(("comment", comment_part.strip()))
|
|
else:
|
|
current_line_outputs.append(("", ""))
|
|
line_actual_object = last_val_for_current_line
|
|
else:
|
|
# Evaluar la línea
|
|
if "__resultado_linea__" in current_pass_context:
|
|
del current_pass_context["__resultado_linea__"]
|
|
|
|
try:
|
|
final_code_to_exec = line_result_capture.analyze_line(code_to_evaluate_stripped)
|
|
processed_result = False # Inicializar al inicio
|
|
|
|
# Verificar si es una ecuación antes de ejecutar
|
|
if '=' in code_to_evaluate_stripped:
|
|
# Intentar agregar como ecuación primero
|
|
if self.sympy_integration.add_equation_from_string(code_to_evaluate_stripped, line_number):
|
|
current_line_outputs.append(("equation", f"Ecuación: {code_to_evaluate_stripped}"))
|
|
line_actual_object = None
|
|
processed_result = True
|
|
|
|
if not processed_result:
|
|
exec(final_code_to_exec, current_pass_context)
|
|
line_actual_object = current_pass_context.get("__resultado_linea__")
|
|
|
|
if isinstance(line_actual_object, SyntaxError):
|
|
raise line_actual_object
|
|
|
|
# Actualizar el contexto de SymPy una vez después de exec y antes del procesamiento simbólico
|
|
self.sympy_integration.update_context(current_pass_context)
|
|
|
|
# Si el resultado de exec es una expresión SymPy, intentar evaluarla/simplificarla más
|
|
if isinstance(line_actual_object, sympy.Expr):
|
|
original_expr_from_exec = line_actual_object
|
|
substitutions = self.sympy_integration._create_substitutions()
|
|
|
|
if substitutions:
|
|
evaluated_expr = original_expr_from_exec.subs(substitutions)
|
|
else:
|
|
evaluated_expr = original_expr_from_exec
|
|
|
|
if evaluated_expr.is_number:
|
|
line_actual_object = self.sympy_integration._format_number(evaluated_expr)
|
|
else:
|
|
simplified_expr = sympy.simplify(evaluated_expr)
|
|
if simplified_expr.is_number:
|
|
line_actual_object = self.sympy_integration._format_number(simplified_expr)
|
|
else:
|
|
line_actual_object = simplified_expr
|
|
|
|
# Procesar el resultado
|
|
if self.sympy_integration.is_equation_string(line_actual_object):
|
|
if self.sympy_integration.add_equation_from_string(line_actual_object, line_number):
|
|
current_line_outputs.append(("equation", f"Ecuación: {line_actual_object}"))
|
|
else:
|
|
current_line_outputs.append(("error", f"Error parseando ecuación: {line_actual_object}"))
|
|
processed_result = True
|
|
|
|
# Manejar instancias de solve
|
|
elif isinstance(line_actual_object, solve):
|
|
solve_obj = line_actual_object
|
|
solve_obj.execute(self.sympy_integration, current_pass_context)
|
|
|
|
# Mostrar output de print si hay
|
|
for type_tag, printed_text in self.output_buffer:
|
|
current_line_outputs.append((type_tag, printed_text))
|
|
|
|
if solve_obj.error:
|
|
current_line_outputs.append(("error", solve_obj.error))
|
|
line_actual_object = last_val_for_current_line
|
|
else:
|
|
current_line_outputs.append(("symbolic", str(solve_obj)))
|
|
if solve_obj.solutions is not None:
|
|
if isinstance(solve_obj.solutions, dict):
|
|
for var_name, sol_value in solve_obj.solutions.items():
|
|
if isinstance(sol_value, (int, float, complex, sympy.Expr, list, tuple)):
|
|
current_pass_context[var_name] = sol_value
|
|
elif solve_obj.var_names and len(solve_obj.var_names) == 1:
|
|
var_name = solve_obj.var_names[0]
|
|
if isinstance(solve_obj.solutions, (int, float, complex, sympy.Expr, list, tuple)):
|
|
current_pass_context[var_name] = solve_obj.solutions
|
|
line_actual_object = solve_obj.solutions
|
|
processed_result = True
|
|
|
|
# Verificar si podemos evaluar simbólicamente expresiones con variables no definidas
|
|
elif not processed_result and isinstance(line_actual_object, NameError):
|
|
sympy_result = self.sympy_integration.evaluate_expression(
|
|
code_to_evaluate_stripped, current_pass_context
|
|
)
|
|
if sympy_result is not None:
|
|
current_line_outputs.append(("symbolic", sympy_result))
|
|
line_actual_object = sympy_result
|
|
processed_result = True
|
|
else:
|
|
current_line_outputs.append(("error", f"NameError: {line_actual_object}"))
|
|
line_actual_object = last_val_for_current_line
|
|
processed_result = True
|
|
|
|
# Resultado normal
|
|
if not processed_result:
|
|
# Mostrar output de print
|
|
for type_tag, printed_text in self.output_buffer:
|
|
current_line_outputs.append((type_tag, printed_text))
|
|
|
|
# Mostrar resultado si no es None
|
|
if line_actual_object is not None:
|
|
current_line_outputs.append(("result", line_actual_object))
|
|
|
|
except NameError as e:
|
|
# Verificar si el error contiene un '=' y tratarlo como ecuación
|
|
if '=' in code_to_evaluate_stripped:
|
|
# Intentar agregar como ecuación
|
|
if self.sympy_integration.add_equation_from_string(code_to_evaluate_stripped, line_number):
|
|
current_line_outputs.append(("equation", f"Ecuación: {code_to_evaluate_stripped}"))
|
|
line_actual_object = None # No hay resultado directo
|
|
else:
|
|
# Intentar evaluar simbólicamente
|
|
sympy_result = self.sympy_integration.evaluate_expression(
|
|
code_to_evaluate_stripped, current_pass_context
|
|
)
|
|
if sympy_result is not None:
|
|
current_line_outputs.append(("symbolic", sympy_result))
|
|
line_actual_object = sympy_result
|
|
else:
|
|
# Mostrar error normal si no se pudo agregar como ecuación
|
|
current_line_outputs.append(("error", f"NameError: {e}"))
|
|
line_actual_object = last_val_for_current_line
|
|
else:
|
|
# Intentar evaluar simbólicamente
|
|
sympy_result = self.sympy_integration.evaluate_expression(
|
|
code_to_evaluate_stripped, current_pass_context
|
|
)
|
|
if sympy_result is not None:
|
|
current_line_outputs.append(("symbolic", sympy_result))
|
|
line_actual_object = sympy_result
|
|
else:
|
|
# Mostrar error normal
|
|
current_line_outputs.append(("error", f"NameError: {e}"))
|
|
line_actual_object = last_val_for_current_line
|
|
|
|
except Exception as e:
|
|
# Mostrar output de print antes del error
|
|
for type_tag, printed_text in self.output_buffer:
|
|
current_line_outputs.append((type_tag, printed_text))
|
|
|
|
# Manejo de errores mejorado
|
|
custom_error_message_applied = False
|
|
if isinstance(e, SyntaxError):
|
|
# Verificar si es un operador al final
|
|
import re
|
|
op_match = re.match(r"(.+?)\s*([+\-*/%^&|<>=!]{1,2})\s*$", code_to_evaluate_stripped)
|
|
if op_match:
|
|
operator = op_match.group(2)
|
|
if operator == "*" and code_to_evaluate_stripped.endswith("**"):
|
|
operator = "**"
|
|
elif operator == "/" and code_to_evaluate_stripped.endswith("//"):
|
|
operator = "//"
|
|
|
|
if operator in self.OPERATOR_HELP_MESSAGES:
|
|
help_text = self.OPERATOR_HELP_MESSAGES[operator]
|
|
error_msg = f"Error de sintaxis cerca de '{operator}'. Ayuda: {help_text}"
|
|
current_line_outputs.append(("error", error_msg))
|
|
custom_error_message_applied = True
|
|
|
|
if not custom_error_message_applied:
|
|
# Verificar si hay ayuda disponible
|
|
hint_message_from_helper = None
|
|
for cls in self.CUSTOM_CLASSES_WITH_HELPERS:
|
|
hint = cls.Helper(code_to_evaluate_stripped)
|
|
if hint:
|
|
hint_message_from_helper = hint
|
|
break
|
|
|
|
if hint_message_from_helper:
|
|
current_line_outputs.append(("comment", hint_message_from_helper))
|
|
else:
|
|
error_type_name = type(e).__name__
|
|
error_detail = e.msg if hasattr(e, 'msg') else str(e)
|
|
if hasattr(e, 'text') and e.text and not str(error_detail).startswith("Sintaxis inválida:"):
|
|
error_detail += f" (en: '{e.text.strip()}')"
|
|
standard_error_message = f"{error_type_name}: {error_detail}"
|
|
current_line_outputs.append(("error", standard_error_message))
|
|
|
|
line_actual_object = last_val_for_current_line
|
|
|
|
# Limpiar variable temporal
|
|
if "__resultado_linea__" in current_pass_context:
|
|
del current_pass_context["__resultado_linea__"]
|
|
|
|
# Agregar comentario si existe
|
|
if comment_part and code_to_evaluate_stripped:
|
|
prefix_space = " " if current_line_outputs else ""
|
|
current_line_outputs.append(("comment", prefix_space + comment_part.strip()))
|
|
|
|
output_data.append(current_line_outputs)
|
|
last_val_for_current_line = line_actual_object
|
|
# Store a copy of the context as it is after this line's evaluation
|
|
self._line_contexts_for_autocomplete[line_number] = current_pass_context.copy()
|
|
|
|
self.set_output_text_with_colors(output_data)
|
|
|
|
def _get_input_font(self):
|
|
"""Obtiene o crea y cachea el objeto tk.Font para el panel de entrada."""
|
|
if not self._cached_input_font:
|
|
# Asume la fuente configurada en create_widgets: ("Consolas", 11)
|
|
self._cached_input_font = tkFont.Font(family="Consolas", size=11)
|
|
return self._cached_input_font
|
|
|
|
def _adjust_input_pane_width(self):
|
|
"""Ajusta el ancho del panel de entrada según su contenido."""
|
|
if not hasattr(self, 'paned_window') or not self.paned_window.winfo_exists():
|
|
return
|
|
|
|
# Esperar a que la ventana tenga un tamaño válido
|
|
if self.paned_window.winfo_width() <= 1:
|
|
return # Se reintentará en la siguiente llamada (ej. por KeyRelease)
|
|
|
|
# Obtener contenido excluyendo el último newline automático del widget Text
|
|
input_content = self.input_text.get("1.0", f"{tk.END}-1c")
|
|
lines = input_content.splitlines()
|
|
input_font = self._get_input_font()
|
|
|
|
max_pixel_width = 0
|
|
if not input_content.strip(): # Si está vacío o solo espacios en blanco
|
|
max_pixel_width = 5 # Ancho mínimo para el cursor o como placeholder
|
|
else:
|
|
for line in lines:
|
|
measured_width = input_font.measure(line) if line.strip() else input_font.measure(" ")
|
|
if measured_width > max_pixel_width:
|
|
max_pixel_width = measured_width
|
|
|
|
padding = 40 # Relleno para barra de desplazamiento, márgenes, etc.
|
|
width_needed_by_text = max_pixel_width + padding
|
|
|
|
# DEBUGGING PRINTS (descomentar si el problema persiste)
|
|
# print(f"--- Adjusting Input Pane ---")
|
|
# print(f"Input content: '{input_content[:50]}...'") # Muestra los primeros 50 chars del input
|
|
# print(f"Max pixel width of text: {max_pixel_width}")
|
|
# print(f"Width needed by text (max_pixel_width + padding): {width_needed_by_text}")
|
|
# END DEBUGGING PRINTS
|
|
|
|
min_input_pane_width = 150 # Definido en create_widgets
|
|
min_output_pane_width = 150 # Definido en create_widgets
|
|
total_width = self.paned_window.winfo_width()
|
|
|
|
current_sash_pos = 0
|
|
try:
|
|
sash_coords = self.paned_window.sash_coord(0)
|
|
if sash_coords:
|
|
current_sash_pos = sash_coords[0]
|
|
else:
|
|
# print("DEBUG: Could not get sash_coord.") # DEBUG
|
|
return
|
|
except tk.TclError:
|
|
# print("DEBUG: TclError getting sash_coord.") # DEBUG
|
|
return
|
|
|
|
# print(f"Current sash position (input pane width): {current_sash_pos}") # DEBUG
|
|
|
|
if width_needed_by_text > current_sash_pos:
|
|
# print(f"Condition MET: Text needs more space ({width_needed_by_text} > {current_sash_pos})") # DEBUG
|
|
new_input_width = width_needed_by_text # Punto de partida
|
|
|
|
# Asegurar que el nuevo ancho no sea menor que el mínimo del panel de entrada
|
|
new_input_width = max(new_input_width, min_input_pane_width)
|
|
|
|
# Asegurar que el panel de salida conserve su espacio mínimo
|
|
if total_width - new_input_width < min_output_pane_width:
|
|
new_input_width = total_width - min_output_pane_width
|
|
new_input_width = max(new_input_width, min_input_pane_width) # Re-verificar mínimo del input
|
|
|
|
# Aplicar un ratio máximo para el panel de entrada
|
|
max_input_ratio = 0.75 # Podría ser una constante de clase
|
|
max_width_by_ratio = int(total_width * max_input_ratio)
|
|
|
|
if new_input_width > max_width_by_ratio:
|
|
if max_width_by_ratio >= min_input_pane_width and \
|
|
(total_width - max_width_by_ratio) >= min_output_pane_width:
|
|
new_input_width = max_width_by_ratio
|
|
|
|
final_new_input_width = max(0, int(new_input_width)) # No debe ser negativo
|
|
# print(f"Calculated final new input width: {final_new_input_width}") # DEBUG
|
|
|
|
# Mover el sash solo si el nuevo ancho es significativamente mayor que el actual (umbral ajustado)
|
|
sash_adjustment_threshold = 3 # Píxeles
|
|
if final_new_input_width > current_sash_pos and \
|
|
(final_new_input_width - current_sash_pos) >= sash_adjustment_threshold:
|
|
# print(f"Condition MET for sash_place: New width {final_new_input_width} is significantly larger (diff >= {sash_adjustment_threshold}).") # DEBUG
|
|
try:
|
|
if self.paned_window.winfo_exists() and total_width >= (min_input_pane_width + min_output_pane_width): # type: ignore
|
|
self.paned_window.sash_place(0, final_new_input_width, 0) # Añadido el argumento y=0
|
|
# print(f"Sash placed at: {final_new_input_width}") # DEBUG
|
|
# else:
|
|
# print("DEBUG: Paned window not ready or total width too small for sash_place.") #DEBUG
|
|
except tk.TclError as e_sash:
|
|
# print(f"DEBUG: TclError during sash_place: {e_sash}") # DEBUG
|
|
pass
|
|
# else:
|
|
# print(f"Condition NOT MET for sash_place: final_new_input_width ({final_new_input_width}) vs current_sash_pos ({current_sash_pos}) or threshold ({sash_adjustment_threshold}).") # DEBUG
|
|
# else:
|
|
# print(f"Condition NOT MET: Text does not need more space ({width_needed_by_text} <= {current_sash_pos})") # DEBUG
|
|
# print(f"--- End Adjusting Input Pane ---")
|
|
|
|
_autocomplete_popup = None
|
|
_autocomplete_listbox = None
|
|
|
|
def _build_context_for_autocomplete(self, current_line_num_1_based):
|
|
"""
|
|
Builds an evaluation context based on lines executed *before* the current line.
|
|
Uses a temporary SymPyIntegration instance to avoid side effects on the main one.
|
|
"""
|
|
lines = self.input_text.get("1.0", tk.END).splitlines()
|
|
|
|
# Start with the base context
|
|
eval_context = self.calc_context_base.copy()
|
|
eval_context["__builtins__"] = self.calc_context_base["__builtins__"].copy()
|
|
|
|
# Use a temporary SymPyIntegration for this context building
|
|
temp_sympy_integration = SymPyIntegration()
|
|
temp_sympy_integration.update_context(eval_context) # Prime with base symbols/values
|
|
|
|
last_val_for_context_build = None
|
|
line_result_capture = LineResultCapture()
|
|
|
|
for i in range(current_line_num_1_based - 1): # Iterate lines *before* current
|
|
if i >= len(lines): break # Should not happen if current_line_num is valid
|
|
|
|
line_text = lines[i]
|
|
eval_context["last"] = last_val_for_context_build
|
|
|
|
code_to_run = line_text
|
|
if "#" in code_to_run:
|
|
code_to_run, _ = code_to_run.split("#", 1)
|
|
code_to_run_stripped = code_to_run.strip()
|
|
|
|
if not code_to_run_stripped:
|
|
# Carry over 'last' or set to None if line is empty.
|
|
# For simplicity, let's assume an empty line doesn't change 'last' from previous.
|
|
continue
|
|
|
|
try:
|
|
final_code_to_exec = line_result_capture.analyze_line(code_to_run_stripped)
|
|
exec(final_code_to_exec, eval_context)
|
|
line_res_obj = eval_context.get("__resultado_linea__")
|
|
|
|
if isinstance(line_res_obj, solve):
|
|
line_res_obj.execute(temp_sympy_integration, eval_context)
|
|
if line_res_obj.solutions is not None:
|
|
if isinstance(line_res_obj.solutions, dict):
|
|
for var_name, sol_val in line_res_obj.solutions.items():
|
|
eval_context[var_name] = sol_val
|
|
elif line_res_obj.var_names and len(line_res_obj.var_names) == 1:
|
|
eval_context[line_res_obj.var_names[0]] = line_res_obj.solutions
|
|
last_val_for_context_build = line_res_obj.solutions
|
|
elif temp_sympy_integration.is_equation_string(line_res_obj):
|
|
temp_sympy_integration.add_equation_from_string(line_res_obj)
|
|
last_val_for_context_build = line_res_obj
|
|
else:
|
|
last_val_for_context_build = line_res_obj
|
|
|
|
temp_sympy_integration.update_context(eval_context)
|
|
|
|
if "__resultado_linea__" in eval_context:
|
|
del eval_context["__resultado_linea__"]
|
|
except Exception:
|
|
pass # Ignore errors in preceding lines for autocomplete context
|
|
|
|
eval_context["last"] = last_val_for_context_build # Set 'last' for the current line's expression
|
|
return eval_context
|
|
|
|
def _handle_dot_autocomplete(self):
|
|
self._close_autocomplete_popup() # Close any existing one first
|
|
|
|
cursor_index_str = self.input_text.index(tk.INSERT)
|
|
line_num_str, char_num_str = cursor_index_str.split('.')
|
|
current_line_num = int(line_num_str) # 1-based
|
|
char_idx_of_dot = int(char_num_str) # 0-based index of char after dot (where cursor is)
|
|
|
|
# Get the text on the current line BEFORE the dot
|
|
# The dot is at char_idx_of_dot - 1
|
|
obj_expr_str = self.input_text.get(f"{current_line_num}.0", f"{current_line_num}.{char_idx_of_dot -1}")
|
|
|
|
if not obj_expr_str.strip():
|
|
return
|
|
|
|
# Build context from lines *before* the current one
|
|
eval_context = self._build_context_for_autocomplete(current_line_num)
|
|
|
|
obj = None
|
|
try:
|
|
obj = eval(obj_expr_str, eval_context)
|
|
except Exception:
|
|
return # Can't evaluate object expression
|
|
|
|
if obj is not None:
|
|
methods = []
|
|
# Prioritize known, useful methods for custom types
|
|
if isinstance(obj, IP4):
|
|
methods.extend(["Mask", "Prefix", "Nodes", "NetworkAddress", "BroadcastAddress", "toHex"])
|
|
elif isinstance(obj, Date):
|
|
methods.extend([]) # No specific simple methods other than operators yet
|
|
# Add other callable attributes, excluding most dunder/private
|
|
|
|
# Define methods from BaseCalcType that should be excluded from suggestions
|
|
# if not overridden by the subclass in a meaningful public way.
|
|
base_calc_type_public_methods_to_exclude = {"Helper", "original_str_for_repr"}
|
|
|
|
for attr_name in dir(obj):
|
|
if attr_name.startswith('_') and not attr_name.startswith('__'): # Exclude single underscore
|
|
continue
|
|
if attr_name.startswith('__'): # Exclude all attributes starting with double underscore
|
|
continue
|
|
if isinstance(obj, BaseCalcType) and attr_name in base_calc_type_public_methods_to_exclude:
|
|
continue
|
|
try:
|
|
attr = getattr(obj, attr_name)
|
|
if callable(attr):
|
|
if attr_name not in methods: # Avoid duplicates
|
|
methods.append(attr_name)
|
|
except Exception: continue
|
|
methods.sort()
|
|
|
|
if methods:
|
|
self._show_autocomplete_popup(methods, obj_expr_str, eval_context)
|
|
|
|
def _show_autocomplete_popup(self, suggestions, obj_expr_str_for_paren, eval_context_for_paren):
|
|
cursor_bbox = self.input_text.bbox(tk.INSERT)
|
|
if not cursor_bbox: return
|
|
|
|
x, y, _, height = cursor_bbox
|
|
popup_x = self.input_text.winfo_rootx() + x
|
|
popup_y = self.input_text.winfo_rooty() + y + height + 2
|
|
|
|
self._autocomplete_popup = tk.Toplevel(self.root)
|
|
self._autocomplete_popup.wm_overrideredirect(True)
|
|
self._autocomplete_popup.wm_geometry(f"+{popup_x}+{popup_y}")
|
|
self._autocomplete_popup.attributes('-topmost', True)
|
|
self.root.after(100, lambda: self._autocomplete_popup.attributes('-topmost', False) if self._autocomplete_popup else None)
|
|
|
|
self._autocomplete_listbox = tk.Listbox(
|
|
self._autocomplete_popup, bg="#3c3f41", fg="#bbbbbb",
|
|
selectbackground="#007acc", selectforeground="white",
|
|
borderwidth=1, relief="solid", exportselection=False, activestyle="none"
|
|
)
|
|
for item in suggestions: self._autocomplete_listbox.insert(tk.END, item)
|
|
|
|
if suggestions:
|
|
self._autocomplete_listbox.select_set(0)
|
|
self._autocomplete_listbox.pack(expand=True, fill=tk.BOTH)
|
|
# Pass the expression and context needed for parenthesis logic
|
|
self._autocomplete_listbox.bind("<Return>", lambda e, expr=obj_expr_str_for_paren, ctx=eval_context_for_paren: self._on_autocomplete_select(e, expr, ctx))
|
|
self._autocomplete_listbox.bind("<Escape>", lambda e: self._close_autocomplete_popup())
|
|
self._autocomplete_listbox.bind("<Double-Button-1>", lambda e, expr=obj_expr_str_for_paren, ctx=eval_context_for_paren: self._on_autocomplete_select(e, expr, ctx))
|
|
self._autocomplete_listbox.focus_set()
|
|
self._autocomplete_listbox.bind("<Up>", lambda e: self._navigate_autocomplete(e, -1))
|
|
self._autocomplete_listbox.bind("<Down>", lambda e: self._navigate_autocomplete(e, 1))
|
|
|
|
self.input_text.bind("<FocusOut>", self._on_input_focus_out_while_autocomplete, add=True)
|
|
self.input_text.bind("<Button-1>", lambda e: self._close_autocomplete_popup(), add=True) # Click in input text
|
|
self.root.bind("<Button-1>", self._on_root_click_while_autocomplete, add=True) # Click outside input text
|
|
self.input_text.bind("<Key>", self._on_input_keypress_while_autocomplete, add=True)
|
|
|
|
max_len = max(len(s) for s in suggestions) if suggestions else 10
|
|
width = max(15, min(max_len + 2, 50))
|
|
height = min(len(suggestions), 10)
|
|
self._autocomplete_listbox.config(width=width, height=height)
|
|
else:
|
|
self._close_autocomplete_popup()
|
|
|
|
def _navigate_autocomplete(self, event, direction):
|
|
if not self._autocomplete_listbox: return "break"
|
|
current_selection = self._autocomplete_listbox.curselection()
|
|
if not current_selection:
|
|
new_idx = 0 if direction == 1 else self._autocomplete_listbox.size() -1
|
|
else:
|
|
idx = current_selection[0]
|
|
new_idx = idx + direction
|
|
|
|
if 0 <= new_idx < self._autocomplete_listbox.size():
|
|
if current_selection: self._autocomplete_listbox.select_clear(current_selection[0])
|
|
self._autocomplete_listbox.select_set(new_idx)
|
|
self._autocomplete_listbox.activate(new_idx)
|
|
self._autocomplete_listbox.see(new_idx)
|
|
return "break"
|
|
|
|
def _on_autocomplete_select(self, event, obj_expr_str, eval_context):
|
|
if not self._autocomplete_listbox: return "break"
|
|
selection = self._autocomplete_listbox.curselection()
|
|
if not selection: self._close_autocomplete_popup(); return "break"
|
|
|
|
selected_method = self._autocomplete_listbox.get(selection[0])
|
|
self.input_text.insert(tk.INSERT, selected_method)
|
|
|
|
try: # Add parentheses if it's a method that usually takes them
|
|
obj = eval(obj_expr_str, eval_context) # Re-eval to get the object
|
|
method_attr = getattr(obj, selected_method, None)
|
|
# Heuristic: if it's callable and not a class type itself (like Hex referring to Hex class)
|
|
# and not a dunder that isn't usually called with () like __str__
|
|
if callable(method_attr) and not isinstance(method_attr, type) \
|
|
and not (selected_method.startswith("__") and selected_method.endswith("__")):
|
|
self.input_text.insert(tk.INSERT, "()")
|
|
self.input_text.mark_set(tk.INSERT, f"{tk.INSERT}-1c") # Cursor inside ()
|
|
except Exception: pass # If unsure, just insert name
|
|
|
|
self._close_autocomplete_popup()
|
|
self.input_text.focus_set()
|
|
self.on_key_release() # Trigger re-evaluation
|
|
return "break"
|
|
|
|
def _close_autocomplete_popup(self, event=None):
|
|
if self._autocomplete_popup:
|
|
self.input_text.unbind("<FocusOut>", self._on_input_focus_out_while_autocomplete_binding_id)
|
|
self.input_text.unbind("<Button-1>") # Reconsider if this is too broad
|
|
self.root.unbind("<Button-1>", self._on_root_click_while_autocomplete_binding_id)
|
|
self.input_text.unbind("<Key>", self._on_input_keypress_while_autocomplete_binding_id)
|
|
|
|
self._autocomplete_popup.destroy()
|
|
self._autocomplete_popup = None
|
|
self._autocomplete_listbox = None
|
|
# Do not shift focus here if it was closed due to focus out or click elsewhere
|
|
|
|
# Store binding IDs to unbind them correctly
|
|
_on_input_focus_out_while_autocomplete_binding_id = None
|
|
_on_root_click_while_autocomplete_binding_id = None
|
|
_on_input_keypress_while_autocomplete_binding_id = None
|
|
|
|
def _on_input_focus_out_while_autocomplete(self, event):
|
|
# Close if focus is not going to the popup itself
|
|
if self._autocomplete_popup and self.root.focus_get() != self._autocomplete_listbox:
|
|
self._close_autocomplete_popup()
|
|
|
|
def _on_root_click_while_autocomplete(self, event):
|
|
if self._autocomplete_popup:
|
|
# Check if the click was outside the input_text and outside the popup
|
|
if event.widget != self.input_text and event.widget != self._autocomplete_listbox and \
|
|
(not isinstance(event.widget, tk.Listbox) or event.widget.master != self._autocomplete_popup):
|
|
self._close_autocomplete_popup()
|
|
|
|
def _on_input_keypress_while_autocomplete(self, event):
|
|
if self._autocomplete_popup:
|
|
if self.root.focus_get() == self._autocomplete_listbox:
|
|
# Let listbox handle Up, Down, Return, Escape
|
|
if event.keysym in ["Up", "Down", "Return", "Escape"]:
|
|
return
|
|
|
|
# For other keys (letters, numbers, backspace, space, etc.) when input has focus
|
|
if event.keysym not in ["Shift_L", "Shift_R", "Control_L", "Control_R", "Alt_L", "Alt_R", "Caps_Lock", "Tab"]:
|
|
if event.char != '.': # Typing a new dot will re-trigger _handle_dot_autocomplete
|
|
self._close_autocomplete_popup()
|
|
# Do not return "break", let the key press be processed by input_text
|
|
# Allow event to propagate
|
|
|
|
# In create_widgets, after self.input_text.bind("<Button-3>", ...):
|
|
# Store binding IDs for later unbinding
|
|
# self._on_input_focus_out_while_autocomplete_binding_id = self.input_text.bind("<FocusOut>", self._on_input_focus_out_while_autocomplete, add=True)
|
|
# self._on_root_click_while_autocomplete_binding_id = self.root.bind("<Button-1>", self._on_root_click_while_autocomplete, add=True)
|
|
# self._on_input_keypress_while_autocomplete_binding_id = self.input_text.bind("<Key>", self._on_input_keypress_while_autocomplete, add=True)
|
|
# The above bindings are now added dynamically when popup is shown.
|
|
|
|
def set_output_text_with_colors(self, output_data_lines):
|
|
self.output_text.config(state="normal")
|
|
self.output_text.delete("1.0", tk.END)
|
|
|
|
num_input_lines_str = self.input_text.index(f"{tk.END}-1c").split(".")[0]
|
|
num_input_lines = int(num_input_lines_str) if num_input_lines_str else 0
|
|
|
|
for i, line_parts in enumerate(output_data_lines):
|
|
is_output_line_effectively_empty = not line_parts or \
|
|
(len(line_parts) == 1 and line_parts[0][0] == "" and line_parts[0][1] == "")
|
|
|
|
if not is_output_line_effectively_empty:
|
|
first_part_on_line = True
|
|
for item_index, (type_tag, content_obj) in enumerate(line_parts):
|
|
if not first_part_on_line:
|
|
prev_type_tag = line_parts[item_index - 1][0] if item_index > 0 else ""
|
|
if type_tag == "comment" and prev_type_tag not in ["", "comment"]:
|
|
self.output_text.insert(tk.END, " ")
|
|
elif prev_type_tag != "":
|
|
self.output_text.insert(tk.END, " ; ")
|
|
|
|
content_str = ""
|
|
type_hint_str = ""
|
|
|
|
if content_obj is not None:
|
|
is_pre_stringified_content = type_tag in ["equation", "error", "comment", "print_output"] or \
|
|
(type_tag == "symbolic" and isinstance(content_obj, str))
|
|
|
|
if is_pre_stringified_content:
|
|
content_str = str(content_obj)
|
|
else:
|
|
content_str = str(content_obj)
|
|
type_name = ""
|
|
if isinstance(content_obj, BaseCalcType):
|
|
type_name = content_obj.__class__.__name__
|
|
elif isinstance(content_obj, sympy.Basic): # sympy.Expr, sympy.Integer, etc.
|
|
type_name = "sympy" # Ejemplo: y = 229/3 [sympy]
|
|
elif isinstance(content_obj, list):
|
|
if content_obj and all(isinstance(item, sympy.Basic) for item in content_obj):
|
|
type_name = "list[sympy]"
|
|
else:
|
|
type_name = "list"
|
|
elif isinstance(content_obj, tuple):
|
|
type_name = "tuple"
|
|
elif isinstance(content_obj, dict):
|
|
type_name = "dict"
|
|
elif not (isinstance(content_obj, BaseCalcType) or \
|
|
isinstance(content_obj, sympy.Basic) or \
|
|
isinstance(content_obj, (list, tuple, dict, str))): # Excluir str aquí
|
|
type_name = type(content_obj).__name__
|
|
elif isinstance(content_obj, str) and type_tag == "result": # String resultado de una función
|
|
type_name = "str"
|
|
|
|
if type_name:
|
|
type_hint_str = f" [{type_name}]"
|
|
|
|
tag_to_apply = type_tag
|
|
|
|
# Aplicar tags especiales según el tipo de objeto
|
|
if type_tag == "result":
|
|
if isinstance(content_obj, Hex): tag_to_apply = "hex"
|
|
elif isinstance(content_obj, Bin): tag_to_apply = "bin"
|
|
elif isinstance(content_obj, IP4): tag_to_apply = "ip"
|
|
elif isinstance(content_obj, Date): tag_to_apply = "date"
|
|
elif isinstance(content_obj, Chr): tag_to_apply = "chr_type_tag"
|
|
|
|
if content_str or type_hint_str: # Solo insertar si hay algo que mostrar
|
|
if content_str: # Puede ser que solo haya hint si content_str es "" (para None, pero se evita)
|
|
self.output_text.insert(tk.END, content_str, tag_to_apply)
|
|
if type_hint_str:
|
|
self.output_text.insert(tk.END, type_hint_str, "type_hint")
|
|
first_part_on_line = False
|
|
|
|
# Agregar saltos de línea
|
|
if i < len(output_data_lines) - 1:
|
|
self.output_text.insert(tk.END, "\n")
|
|
elif i < num_input_lines - 1:
|
|
self.output_text.insert(tk.END, "\n")
|
|
|
|
self.output_text.config(state="disabled")
|
|
|
|
def save_persistent_input(self):
|
|
try:
|
|
content_to_save = self.input_text.get("1.0", tk.END).rstrip("\n")
|
|
if content_to_save:
|
|
with open("calc_input_history.txt", "w", encoding="utf-8") as f:
|
|
f.write(content_to_save)
|
|
elif os.path.exists("calc_input_history.txt"):
|
|
os.remove("calc_input_history.txt")
|
|
except Exception as e:
|
|
messagebox.showwarning("Error", f"No se pudo guardar el historial: {e}")
|
|
|
|
def load_persistent_input(self):
|
|
try:
|
|
if os.path.exists("calc_input_history.txt"):
|
|
with open("calc_input_history.txt", "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
if content:
|
|
self.input_text.insert("1.0", content)
|
|
self.root.after_idle(self._process_input_and_adjust_layout)
|
|
return
|
|
|
|
# Ejemplo por defecto
|
|
example_code = '''# Calculadora MAV - Ejemplos
|
|
# Evaluación directa de Python
|
|
2 + 2 * 3
|
|
pi * 2
|
|
sin(pi/2)
|
|
|
|
# Clases especiales
|
|
Hex("FF") + 1
|
|
IP4("192.168.1.1") + 255
|
|
|
|
# Nueva clase Chr para ASCII
|
|
Chr("A")
|
|
Chr("Hello")
|
|
Dec(66).toChr() # Devuelve Chr('B')
|
|
Hex(0x43).toChr() # Devuelve Chr('C')
|
|
# Ecuaciones algebraicas (usar comillas)
|
|
"x + 10 = 15"
|
|
"a*y - 5 = 20"
|
|
|
|
# Resolver ecuaciones
|
|
solve(x)
|
|
solve(y)
|
|
|
|
# Definir variables después
|
|
a = 3
|
|
|
|
# Evaluar expresiones con variables
|
|
y/25
|
|
|
|
# Expresiones simbólicas
|
|
b + c * 2
|
|
'''
|
|
self.input_text.insert("1.0", example_code)
|
|
self.root.after_idle(self._process_input_and_adjust_layout)
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"No se pudo cargar el historial o ejemplos: {e}")
|
|
|
|
def on_close(self):
|
|
self.save_persistent_input()
|
|
self._save_app_settings()
|
|
self.root.destroy()
|
|
|
|
def _process_input_and_adjust_layout(self):
|
|
"""Evalúa todas las líneas y luego ajusta el ancho del panel de entrada."""
|
|
self.evaluate_all_lines()
|
|
self._adjust_input_pane_width()
|
|
|
|
# --- Métodos actualizados para usar _process_input_and_adjust_layout ---
|
|
|
|
def on_key_release(self, event=None):
|
|
if self._debounce_job:
|
|
self.root.after_cancel(self._debounce_job)
|
|
|
|
if event and event.char == '.' and self.input_text.focus_get() == self.input_text:
|
|
self._handle_dot_autocomplete()
|
|
|
|
self._debounce_job = self.root.after(250, self._process_input_and_adjust_layout)
|
|
|
|
def _clear_input_text(self):
|
|
self.input_text.delete("1.0", tk.END)
|
|
self.sympy_integration.clear()
|
|
self._process_input_and_adjust_layout()
|
|
|
|
def _save_input_as(self): # Este método no cambia su lógica interna de guardado
|
|
filepath = filedialog.asksaveasfilename(
|
|
defaultextension=".txt",
|
|
filetypes=[("Text Files", "*.txt"), ("Python Files", "*.py"), ("All Files", "*.*")],
|
|
title="Guardar entrada como..."
|
|
)
|
|
if not filepath: return
|
|
try:
|
|
with open(filepath, "w", encoding="utf-8") as f:
|
|
f.write(self.input_text.get("1.0", tk.END))
|
|
except IOError as e:
|
|
messagebox.showerror("Error al guardar", f"No se pudo guardar el archivo:\n{e}")
|
|
|
|
def _load_input_from_file(self):
|
|
filepath = filedialog.askopenfilename(
|
|
filetypes=[("Text Files", "*.txt"), ("Python Files", "*.py"), ("All Files", "*.*")],
|
|
title="Cargar entrada desde archivo..."
|
|
)
|
|
if not filepath: return
|
|
try:
|
|
with open(filepath, "r", encoding="utf-8") as f: content = f.read()
|
|
self.input_text.delete("1.0", tk.END)
|
|
self.input_text.insert("1.0", content)
|
|
self._process_input_and_adjust_layout() # Evaluar y ajustar layout
|
|
except IOError as e: messagebox.showerror("Error al cargar", f"No se pudo cargar el archivo:\n{e}")
|
|
except Exception as e: messagebox.showerror("Error al procesar", f"Error al procesar el archivo:\n{e}")
|
|
|
|
def main():
|
|
root = tk.Tk()
|
|
app = CalculatorApp(root)
|
|
root.protocol("WM_DELETE_WINDOW", app.on_close)
|
|
try:
|
|
root.iconname("Calculadora MAV")
|
|
except tk.TclError:
|
|
pass
|
|
root.mainloop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|