From 1ac67ba9bd51282a0f3df94668b36a7b5397de87 Mon Sep 17 00:00:00 2001 From: Levi Planelles Date: Mon, 19 Jan 2026 19:39:44 +0100 Subject: [PATCH] add chat system --- .DS_Store | Bin 0 -> 6148 bytes README.md | 6 + __pycache__/chat_client.cpython-314.pyc | Bin 0 -> 4552 bytes __pycache__/chat_server.cpython-314.pyc | Bin 0 -> 12149 bytes chat_client.py | 88 ++ chat_server.py | 192 ++++ para_enviar.txt | 88 ++ proyecto.py | 177 +++- proyecto.py.bak | 1210 +++++++++++++++++++++++ 9 files changed, 1749 insertions(+), 12 deletions(-) create mode 100644 .DS_Store create mode 100644 __pycache__/chat_client.cpython-314.pyc create mode 100644 __pycache__/chat_server.cpython-314.pyc create mode 100644 chat_client.py create mode 100644 chat_server.py create mode 100644 para_enviar.txt create mode 100644 proyecto.py.bak diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..140c1186ef4ad79ead6ee8a22d20b1cf3169d285 GIT binary patch literal 6148 zcmeHK%}&BV5dO9j10hk49vdHk#5bq}4j#Pd10WQF1O!sR#9N=i=kVkU>Nm3+q(6Ez z#>_Oc-*!9S&hEFHZVSNm_LD211E5J4j9M&eOrDEZED9~cbJ|CW5mG#2ieV+%8vddJ zvUY8*)({)KaP7X%%m~>YJ$m;;jWysDV~t*5k4-7|y%c+F{di|cBEtrcNpLrecTee9Py;Y5Iu{GEk~EJ?+vecbzZ9CF@qhL znF6MODR5ALXSP_g?a@k8z!WeA4hqQoA*Kr^0dtS~>R=(CPx8DWD8{;cEDFW}lYqHL zKA|~JC3>n0zZlNb*&c_uBw+5*)8RBT`w5v{_(O4;o$axP!zCWAGzCn7q5>!Wwj<~N z;QRmoBFXMd0aM^#Dc~CY`+kp83TJC&a&p$j^ar|_#N{3j6jt;oW~>~=7j!kY$5J6C U0dtRRq1lIk%3y^l@T&@Z0xNlpiU0rr literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 84af9cd..79ef431 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,12 @@ Características principales - Juego de carreras (canvas): [`proyecto.open_game_race`](proyecto.py) - Abrir aplicaciones macOS con `open`: [`proyecto.launch_app`](proyecto.py) - Monitor de red en la barra de estado: [`proyecto.network_monitor`](proyecto.py) +- **Chat TCP (Multiusuario):** + - **Acceso:** Desde pestaña "Enlaces" -> Botón "Chat TCP". + - **Servidor:** Al lanzarlo, se integra en el **panel derecho de la ventana principal**. Desde ahí se visualizan logs de conexión y mensajería en tiempo real, y permite enviar mensajes como Administrador a todos los conectados. [Código servidor](chat_server.py). + - **Cliente:** + - *Desde la App:* Abre una terminal externa independiente para chatear. + - *Desde otro PC:* Solo requiere el archivo `chat_client.py` (sin dependencias extra). Ejecutar: `python chat_client.py --host IP_SERVIDOR --name TuNombre`. [Código cliente](chat_client.py). Configuración y datos sensibles ------------------------------- diff --git a/__pycache__/chat_client.cpython-314.pyc b/__pycache__/chat_client.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc03097c4ed30dc1ff49edd93148aef69fcee317 GIT binary patch literal 4552 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 RtowROOwU~!Fs&Y6$rQe&~tKAxpO@LdMBa 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 RSF0$*t7*jRD!Mn){|o*4s3QOX literal 0 HcmV?d00001 diff --git a/chat_client.py b/chat_client.py new file mode 100644 index 0000000..70cf9b5 --- /dev/null +++ b/chat_client.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +import argparse +import socket +import sys +import threading + + +DEFAULT_PORT = 5050 +ENCODING = "utf-8" + + +def _receiver_loop(rfile) -> None: + while True: + try: + line = rfile.readline() + except Exception: + break + if not line: + break + # Imprime mensajes entrantes en tiempo real + sys.stdout.write(line) + sys.stdout.flush() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cliente de chat TCP") + parser.add_argument("--host", help="IP/host del servidor (ej: 192.168.1.10)") + parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Puerto (por defecto {DEFAULT_PORT})") + parser.add_argument("--name", help="Tu nombre en el chat") + args = parser.parse_args() + + host = args.host or input("IP del servidor: ").strip() + if not host: + print("Host vacío. Abortando.") + return 2 + + name = args.name or input("Tu nombre: ").strip() + if not name: + name = "Anon" + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, args.port)) + + rfile = sock.makefile("r", encoding=ENCODING, newline="\n") + wfile = sock.makefile("w", encoding=ENCODING, newline="\n") + + # Handshake simple con el servidor + first = rfile.readline() + if first and first.strip() == "NAME?": + wfile.write(f"NAME {name}\n") + wfile.flush() + else: + # Si el servidor no pide nombre, lo mandamos igualmente. + wfile.write(f"NAME {name}\n") + wfile.flush() + if first: + sys.stdout.write(first) + sys.stdout.flush() + + t = threading.Thread(target=_receiver_loop, args=(rfile,), daemon=True) + t.start() + + print("Conectado. Escribe mensajes y pulsa Enter. /quit para salir.") + + try: + for line in sys.stdin: + msg = line.rstrip("\n") + wfile.write(msg + "\n") + wfile.flush() + if msg.lower() in {"/quit", "/exit"}: + break + except KeyboardInterrupt: + try: + wfile.write("/quit\n") + wfile.flush() + except Exception: + pass + finally: + try: + sock.close() + except Exception: + pass + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/chat_server.py b/chat_server.py new file mode 100644 index 0000000..11068e1 --- /dev/null +++ b/chat_server.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +import socket +import threading +from dataclasses import dataclass + +DEFAULT_HOST = "0.0.0.0" +DEFAULT_PORT = 5050 +ENCODING = "utf-8" + +@dataclass +class ClientConn: + sock: socket.socket + addr: tuple + name: str + wfile: any + +class ChatServer: + def __init__(self, host=DEFAULT_HOST, port=DEFAULT_PORT, on_log=None): + self.host = host + self.port = port + self.on_log = on_log if on_log else print + self.clients = {} + self.clients_lock = threading.Lock() + self.server_sock = None + self.running = False + self.thread = None + + def log(self, message): + """Envía el mensaje al callback configurado (GUI o print)""" + self.on_log(message) + + def broadcast(self, message: str, exclude: socket.socket | None = None) -> None: + """Envía mensaje a todos los clientes conectados""" + line = message.rstrip("\n") + "\n" + with self.clients_lock: + items = list(self.clients.items()) + + # Log local si no está excluido el propio servidor (opcional, pero útil ver lo que se envía) + # self.log(f"BRD: {message}") + + for sock, client in items: + if exclude is not None and sock is exclude: + continue + try: + client.wfile.write(line) + client.wfile.flush() + except Exception: + pass + + def send_server_message(self, text: str): + """Mensaje desde el servidor (admin)""" + msg = f"[ADMIN] {text}" + self.log(msg) + self.broadcast(msg) + + def _handle_client(self, conn: socket.socket, addr: tuple) -> None: + try: + rfile = conn.makefile("r", encoding=ENCODING, newline="\n") + wfile = conn.makefile("w", encoding=ENCODING, newline="\n") + + name = None + + # Protocolo simple: pedir nombre + try: + wfile.write("NAME?\n") + wfile.flush() + except Exception: + return + + try: + raw_name = rfile.readline() + except Exception: + raw_name = None + + if not raw_name: + return + + raw_name = raw_name.strip() + # Compatibilidad si el cliente manda "NAME Pepe" + if raw_name.upper().startswith("NAME "): + raw_name = raw_name[5:].strip() + + name = raw_name or f"{addr[0]}:{addr[1]}" + + with self.clients_lock: + self.clients[conn] = ClientConn(sock=conn, addr=addr, name=name, wfile=wfile) + + msg_join = f"* {name} se ha unido al chat *" + self.log(msg_join) + self.broadcast(msg_join) + + while self.running: + try: + line = rfile.readline() + except Exception: + break + + if not line: + break + + msg = line.strip() + if not msg: + continue + + if msg.lower() in {"/quit", "/exit"}: + break + + # Mostrar en servidor y reenviar a otros + full_msg = f"[{name}] {msg}" + self.log(full_msg) + self.broadcast(full_msg) + + except Exception as e: + self.log(f"Error gestionando cliente {addr}: {e}") + finally: + with self.clients_lock: + self.clients.pop(conn, None) + try: + conn.close() + except Exception: + pass + + if name: + msg_left = f"* {name} ha salido del chat *" + self.log(msg_left) + self.broadcast(msg_left) + + def start_background(self): + """Inicia el servidor en un hilo secundario""" + if self.running: + return + self.running = True + self.thread = threading.Thread(target=self._run_server_loop, daemon=True) + self.thread.start() + + def stop(self): + """Detiene el servidor""" + self.running = False + if self.server_sock: + try: + self.server_sock.close() + except Exception: + pass + self.log("Servidor detenido.") + + def _run_server_loop(self): + self.server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + try: + self.server_sock.bind((self.host, self.port)) + self.server_sock.listen(100) + self.server_sock.settimeout(1.0) # Timeout para permitir verificar self.running + self.log(f"Servidor escuchando en {self.host}:{self.port}") + + while self.running: + try: + conn, addr = self.server_sock.accept() + t = threading.Thread(target=self._handle_client, args=(conn, addr), daemon=True) + t.start() + except TimeoutError: + continue + except OSError: + break + except Exception as e: + self.log(f"Error aceptando conexión: {e}") + + except Exception as e: + self.log(f"Error fatal en servidor: {e}") + finally: + self.running = False + try: + self.server_sock.close() + except Exception: + pass + +if __name__ == "__main__": + # Modo standalone para pruebas + import sys + try: + srv = ChatServer(port=5050) + srv.start_background() + print("Presiona Ctrl+C para salir.") + while True: + cmd = sys.stdin.readline() + if not cmd: break + if cmd.strip(): + srv.send_server_message(cmd.strip()) + except KeyboardInterrupt: + pass + finally: + srv.stop() diff --git a/para_enviar.txt b/para_enviar.txt new file mode 100644 index 0000000..70cf9b5 --- /dev/null +++ b/para_enviar.txt @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +import argparse +import socket +import sys +import threading + + +DEFAULT_PORT = 5050 +ENCODING = "utf-8" + + +def _receiver_loop(rfile) -> None: + while True: + try: + line = rfile.readline() + except Exception: + break + if not line: + break + # Imprime mensajes entrantes en tiempo real + sys.stdout.write(line) + sys.stdout.flush() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cliente de chat TCP") + parser.add_argument("--host", help="IP/host del servidor (ej: 192.168.1.10)") + parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Puerto (por defecto {DEFAULT_PORT})") + parser.add_argument("--name", help="Tu nombre en el chat") + args = parser.parse_args() + + host = args.host or input("IP del servidor: ").strip() + if not host: + print("Host vacío. Abortando.") + return 2 + + name = args.name or input("Tu nombre: ").strip() + if not name: + name = "Anon" + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, args.port)) + + rfile = sock.makefile("r", encoding=ENCODING, newline="\n") + wfile = sock.makefile("w", encoding=ENCODING, newline="\n") + + # Handshake simple con el servidor + first = rfile.readline() + if first and first.strip() == "NAME?": + wfile.write(f"NAME {name}\n") + wfile.flush() + else: + # Si el servidor no pide nombre, lo mandamos igualmente. + wfile.write(f"NAME {name}\n") + wfile.flush() + if first: + sys.stdout.write(first) + sys.stdout.flush() + + t = threading.Thread(target=_receiver_loop, args=(rfile,), daemon=True) + t.start() + + print("Conectado. Escribe mensajes y pulsa Enter. /quit para salir.") + + try: + for line in sys.stdin: + msg = line.rstrip("\n") + wfile.write(msg + "\n") + wfile.flush() + if msg.lower() in {"/quit", "/exit"}: + break + except KeyboardInterrupt: + try: + wfile.write("/quit\n") + wfile.flush() + except Exception: + pass + finally: + try: + sock.close() + except Exception: + pass + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/proyecto.py b/proyecto.py index 5ee5cd1..3ab4a0b 100644 --- a/proyecto.py +++ b/proyecto.py @@ -1,7 +1,7 @@ import tkinter as tk -from tkinter import Menu # Importar el widget Menu -from tkinter import ttk # Importar el widget ttk +from tkinter import Menu, ttk, scrolledtext # Importar el widget Menu, ttk y scrolledtext import threading +import sys import time import datetime import webbrowser @@ -52,6 +52,10 @@ try: except Exception: HAS_PYGAME = False +# Import chat TCP (servidor y cliente) +import chat_server +import chat_client + def update_time(label_widget): """Función que actualiza la hora y el día de la semana en un label. @@ -168,6 +172,128 @@ def backup_ui(): pass +def open_chat_window(): + """Abre ventana de configuración para lanzar servidor o cliente de chat""" + win = tk.Toplevel(root) + win.title("Chat TCP - Configuración") + win.geometry("450x400") + win.minsize(400, 350) + + # Estilo personalizado para títulos + header_font = ("Helvetica", 16, "bold") + label_font = ("Helvetica", 11) + + # Contenedor principal + content = tk.Frame(win, bg="#f5f5f5", padx=20, pady=20) + content.pack(fill="both", expand=True) + + # Título + tk.Label(content, text="Iniciar Chat", font=header_font, bg="#f5f5f5", fg="#333").pack(pady=(0, 20)) + + # --- Sección ROL --- + rol_frame = tk.Frame(content, bg="white", bd=1, relief="solid", padx=15, pady=15) + rol_frame.pack(fill="x", pady=(0, 15)) + + tk.Label(rol_frame, text="Selecciona tu rol:", font=("Helvetica", 12, "bold"), bg="white").pack(anchor="w", pady=(0, 10)) + + mode_var = tk.StringVar(value="server") + + # Sub-frame para radios + radios_f = tk.Frame(rol_frame, bg="white") + radios_f.pack(fill="x") + + r1 = tk.Radiobutton(radios_f, text="Servidor (Anfitrión)", variable=mode_var, value="server", bg="white", font=label_font) + r1.pack(side="left", padx=(0, 20)) + r2 = tk.Radiobutton(radios_f, text="Cliente (Invitado)", variable=mode_var, value="client", bg="white", font=label_font) + r2.pack(side="left") + + # --- Sección DATOS --- + data_frame = tk.Frame(content, bg="white", bd=1, relief="solid", padx=15, pady=15) + data_frame.pack(fill="x", pady=(0, 20)) + + # Grid para formulario + tk.Label(data_frame, text="Puerto:", bg="white", font=label_font).grid(row=0, column=0, sticky="e", padx=5, pady=5) + port_var = tk.StringVar(value="5050") + tk.Entry(data_frame, textvariable=port_var, width=10, font=label_font).grid(row=0, column=1, sticky="w", padx=5, pady=5) + + tk.Label(data_frame, text="IP Servidor:", bg="white", font=label_font).grid(row=1, column=0, sticky="e", padx=5, pady=5) + host_var = tk.StringVar(value="127.0.0.1") + ip_entry = tk.Entry(data_frame, textvariable=host_var, width=20, font=label_font) + ip_entry.grid(row=1, column=1, sticky="w", padx=5, pady=5) + + tk.Label(data_frame, text="(Solo Cliente)", bg="white", fg="gray", font=("Arial", 9)).grid(row=1, column=2, sticky="w", padx=5) + + def toggle_ip(*args): + if mode_var.get() == "server": + ip_entry.config(state="disabled", bg="#eee") + else: + ip_entry.config(state="normal", bg="white") + + mode_var.trace_add("write", toggle_ip) + toggle_ip() + + # --- Botón Acción --- + def run_action(): + mode = mode_var.get() + try: + port = int(port_var.get()) + except ValueError: + mb.showerror("Error", "El puerto debe ser numérico") + return + + if mode == "server": + win.destroy() + start_server_in_ui(port) + else: + host = host_var.get() + win.destroy() + open_client_terminal(host, port) + + # Botón usando ttk para asegurar renderizado nativo correcto sobre frame + # A veces tk.Button da problemas de z-order. Usamos un botón grande. + btn_frame = tk.Frame(content, bg="#f5f5f5", pady=10) + btn_frame.pack(fill="x", side="bottom") + + btn = tk.Button(btn_frame, text="LANZAR APLICACIÓN", command=run_action, + bg="#007aff", fg="black", font=("Helvetica", 14, "bold"), height=2) + # En macOS 'fg' a veces no va, pero el botón debería verse. + # Usamos pack con fill x + btn.pack(fill="x") + + + +def open_client_terminal(host, port): + """Abre terminal externa para el cliente (interactivo)""" + if not host: + host = "127.0.0.1" + + # Intentar detectar terminal según OS + # macOS + cmd_script = f'tell application "Terminal" to do script "cd \\"{os.getcwd()}\\" && \\"{sys.executable}\\" chat_client.py --host {host} --port {port} --name UsuarioGUI"' + try: + subprocess.run(["osascript", "-e", cmd_script]) + except Exception as e: + mb.showerror("Error", f"No se pudo lanzar terminal:\n{e}") + + +def start_server_in_ui(port): + """Inicia el servidor y conecta los logs al sidebar derecho""" + global global_chat_server + + if global_chat_server and global_chat_server.running: + mb.showinfo("Servidor", "El servidor ya está corriendo. Detenlo primero si quieres reiniciar.") + return + + try: + global_chat_server = chat_server.ChatServer(port=port, on_log=append_chat_log) + global_chat_server.start_background() + append_chat_log(f">>> Servidor iniciado en puerto {port}") + mb.showinfo("Servidor", f"Servidor iniciado correctamente en puerto {port}.\nVer logs en panel derecho.") + except Exception as e: + mb.showerror("Error", f"Error al iniciar servidor:\n{e}") + append_chat_log(f"Error inicio: {e}") + + def open_resource_window(): if not HAS_MATPLOTLIB: mb.showwarning("Dependencia", "matplotlib no está disponible. Instálalo con pip install matplotlib") @@ -918,18 +1044,38 @@ sec_batch.pack(fill="x", padx=8, pady=(12,6)) btn_backup = ttk.Button(frame_izquierdo, text="Copias de seguridad", width=18, style="Secondary.TButton") btn_backup.pack(pady=6, padx=8, fill='x') # --- Contenido del sidebar derecho (chat y lista de alumnos) --- -chat_title = tk.Label(frame_derecho, text="Chat", font=("Helvetica", 14, "bold"), bg=PALETTE["sidebar"]) +chat_title = tk.Label(frame_derecho, text="Chat Servidor", font=("Helvetica", 14, "bold"), bg=PALETTE["sidebar"]) chat_title.pack(pady=(8,8)) -msg_label = tk.Label(frame_derecho, text="Mensaje", bg=PALETTE["sidebar"], font=FONT_NORMAL) +# Área de logs del chat (solo lectura) +chat_log = scrolledtext.ScrolledText(frame_derecho, height=10, width=26, bd=0, relief="flat", state="disabled", font=("Helvetica", 9)) +chat_log.pack(padx=8, pady=(0, 6), fill="x", expand=False) + +msg_label = tk.Label(frame_derecho, text="Mensaje Admin", bg=PALETTE["sidebar"], font=FONT_NORMAL) msg_label.pack(padx=8, anchor="w") -msg_text = tk.Text(frame_derecho, height=6, width=26, bd=0, relief="flat") -msg_text.pack(padx=8, pady=(6,8), fill="x") +msg_text = tk.Text(frame_derecho, height=3, width=26, bd=0, relief="flat") +msg_text.pack(padx=8, pady=(2,8), fill="x") send_btn = ttk.Button(frame_derecho, text="Enviar", style="Accent.TButton") send_btn.pack(padx=8, pady=(0,12)) +# Variable global para la instancia del servidor +global_chat_server = None + +def append_chat_log(msg): + """Callback para añadir logs al área de chat del sidebar""" + def _u(): + chat_log.config(state="normal") + chat_log.insert("end", str(msg) + "\n") + chat_log.see("end") + chat_log.config(state="disabled") + # Asegurar ejecución en hilo principal + try: + chat_log.after(0, _u) + except Exception: + pass + alumnos_label = tk.Label(frame_derecho, text="Alumnos", bg=PALETTE["sidebar"], font=FONT_TITLE) alumnos_label.pack(padx=8, anchor="w") @@ -972,16 +1118,22 @@ btn_buscar.config(command=fetch_weather_xabia) # Los controles de alarma y reproducción de música están disponibles en la pestaña "Enlaces" -# Enviar mensaje (simulado) -def send_message(): +# Enviar mensaje (Chat Servidor) +def send_message(event=None): + # Si viene de evento KeyRelease/Return, evitar salto de línea extra si es Text text = msg_text.get("1.0", "end-1c").strip() if not text: - mb.showwarning("Mensaje", "El mensaje está vacío") - return - mb.showinfo("Mensaje", "Mensaje enviado (simulado)") - msg_text.delete("1.0", "end") + return "break" + + if global_chat_server and global_chat_server.running: + global_chat_server.send_server_message(text) + msg_text.delete("1.0", "end") + else: + mb.showwarning("Chat Servidor", "El servidor no está iniciado.\nVe a 'Enlaces > Chat TCP' e inicia el servidor.") + return "break" send_btn.config(command=send_message) +msg_text.bind("", send_message) # Dividir el frame central en dos partes (superior variable e inferior fija) frame_central.rowconfigure(0, weight=1) # Parte superior, tamaño variable @@ -1155,6 +1307,7 @@ links_frame = tk.Frame(tab_enlaces) links_frame.pack(fill="both", expand=True, padx=8, pady=8) ttk.Button(links_frame, text="Abrir Visual Studio Code", command=lambda: launch_app("/Applications/Visual Studio Code.app"), style="Accent.TButton").pack(fill="x", pady=4) ttk.Button(links_frame, text="Abrir Spotify", command=lambda: launch_app("/Applications/Spotify.app"), style="Secondary.TButton").pack(fill="x", pady=4) +ttk.Button(links_frame, text="Chat TCP (Servidor/Cliente)", command=open_chat_window, style="Accent.TButton").pack(fill="x", pady=4) ttk.Button(links_frame, text="Mostrar recursos (matplotlib)", command=open_resource_window, style="Secondary.TButton").pack(fill="x", pady=4) ttk.Button(links_frame, text="Reproducir música (archivo)", command=play_music_file, style="Secondary.TButton").pack(fill="x", pady=4) ttk.Button(links_frame, text="Detener música", command=stop_music, style="Secondary.TButton").pack(fill="x", pady=4) diff --git a/proyecto.py.bak b/proyecto.py.bak new file mode 100644 index 0000000..5ee5cd1 --- /dev/null +++ b/proyecto.py.bak @@ -0,0 +1,1210 @@ +import tkinter as tk +from tkinter import Menu # Importar el widget Menu +from tkinter import ttk # Importar el widget ttk +import threading +import time +import datetime +import webbrowser +import subprocess +import psutil +import random +import tkinter.filedialog as fd +import tkinter.messagebox as mb +import tkinter.simpledialog as sd +import os +import shutil +from threading import Event +# Mapa de eventos para detener carreras por canvas +race_stop_events = {} +# Música: control global para reproducción/parada +music_lock = threading.Lock() +music_process = None +music_current = None +music_playing = False +# Alarma: control global +alarm_control = { + "event": None, + "thread": None, + "end_ts": None +} + +# Optional heavy imports guarded +try: + import matplotlib + matplotlib.use("TkAgg") + import matplotlib.pyplot as plt + from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg + HAS_MATPLOTLIB = True +except Exception: + HAS_MATPLOTLIB = False + +try: + import requests + from bs4 import BeautifulSoup + HAS_REQUESTS = True +except Exception: + HAS_REQUESTS = False + +try: + import pygame + pygame.mixer.init() + HAS_PYGAME = True +except Exception: + HAS_PYGAME = False + +def update_time(label_widget): + """Función que actualiza la hora y el día de la semana en un label. + + Se ejecuta en un hilo secundario y programa las actualizaciones en + el hilo principal de Tkinter usando el método `after` del widget. + """ + while True: + now = datetime.datetime.now() + day_of_week = now.strftime("%A") + time_str = now.strftime("%H:%M:%S") + date_str = now.strftime("%Y-%m-%d") + label_text = f"{day_of_week}, {date_str} - {time_str}" + + # Programar la actualización en el hilo principal + try: + label_widget.after(0, label_widget.config, {"text": label_text}) + except Exception: + # Si el widget ya no existe, salir del bucle + break + + time.sleep(1) + + +def launch_browser(url): + try: + webbrowser.open(url) + except Exception as e: + mb.showerror("Error", f"No se pudo abrir el navegador:\n{e}") + + +def launch_browser_prompt(): + url = sd.askstring("Abrir navegador", "Introduce la URL:", initialvalue="https://www.google.com") + if url: + threading.Thread(target=launch_browser, args=(url,), daemon=True).start() + + +def run_backup_script(): + script = fd.askopenfilename(title="Selecciona script .ps1", filetypes=[("PowerShell", "*.ps1"), ("All", "*")]) + if not script: + return + + def runner(path): + # Intentar usar pwsh o powershell + for exe in ("pwsh", "powershell", "pwsh.exe", "powershell.exe"): + try: + subprocess.run([exe, "-File", path], check=True) + mb.showinfo("Backup", "Script ejecutado correctamente") + return + except FileNotFoundError: + continue + except subprocess.CalledProcessError as e: + mb.showerror("Error", f"El script devolvió error:\n{e}") + return + mb.showerror("Error", "No se encontró PowerShell en el sistema") + + threading.Thread(target=runner, args=(script,), daemon=True).start() + + +def _copy_path_to_backup(path): + """Worker: copia `path` (archivo o carpeta) dentro de ./backup del proyecto. + Se ejecuta en un hilo de fondo. + """ + base_dir = os.path.abspath(os.path.dirname(__file__)) + backup_dir = os.path.join(base_dir, "backup") + try: + os.makedirs(backup_dir, exist_ok=True) + except Exception: + pass + + try: + if os.path.isfile(path): + name = os.path.basename(path) + dest = os.path.join(backup_dir, name) + # si existe, añadir timestamp + if os.path.exists(dest): + stem, ext = os.path.splitext(name) + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + dest = os.path.join(backup_dir, f"{stem}_{ts}{ext}") + shutil.copy2(path, dest) + root.after(0, mb.showinfo, "Backup", f"Archivo copiado en:\n{dest}") + elif os.path.isdir(path): + name = os.path.basename(os.path.normpath(path)) or 'folder' + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + dest = os.path.join(backup_dir, f"{name}_{ts}") + # usar copytree + shutil.copytree(path, dest) + root.after(0, mb.showinfo, "Backup", f"Carpeta copiada en:\n{dest}") + else: + root.after(0, mb.showwarning, "Backup", "La ruta seleccionada no es válida") + except Exception as e: + try: + root.after(0, mb.showerror, "Backup", f"Error al copiar:\n{e}") + except Exception: + pass + + +def backup_ui(): + """Interfaz: pedir al usuario que seleccione archivo o carpeta y lanzar copia en background. + Debe ejecutarse en el hilo principal (dialogos).""" + # Primero intentar seleccionar un archivo + path = fd.askopenfilename(title="Selecciona archivo para copiar (Cancelar para elegir carpeta)") + if path: + threading.Thread(target=_copy_path_to_backup, args=(path,), daemon=True).start() + return + # Si no eligió archivo, permitir elegir carpeta + dirpath = fd.askdirectory(title="Selecciona carpeta para copiar (si cancelas, se aborta)") + if dirpath: + threading.Thread(target=_copy_path_to_backup, args=(dirpath,), daemon=True).start() + return + # si cancela ambas, informar + try: + mb.showinfo("Backup", "Operación cancelada") + except Exception: + pass + + +def open_resource_window(): + if not HAS_MATPLOTLIB: + mb.showwarning("Dependencia", "matplotlib no está disponible. Instálalo con pip install matplotlib") + return + + win = tk.Toplevel(root) + win.title("Recursos del sistema") + fig, axes = plt.subplots(3, 1, figsize=(6, 6)) + canvas = FigureCanvasTkAgg(fig, master=win) + canvas.get_tk_widget().pack(fill="both", expand=True) + + xdata = list(range(30)) + cpu_data = [0]*30 + mem_data = [0]*30 + net_data = [0]*30 + + line_cpu, = axes[0].plot(xdata, cpu_data, label="CPU %") + axes[0].set_ylim(0, 100) + line_mem, = axes[1].plot(xdata, mem_data, label="Mem %", color="orange") + axes[1].set_ylim(0, 100) + line_net, = axes[2].plot(xdata, net_data, label="KB/s", color="green") + + axes[0].legend(loc="upper right") + axes[1].legend(loc="upper right") + axes[2].legend(loc="upper right") + + prev_net = psutil.net_io_counters() + + after_id = None + + def on_close(): + nonlocal after_id + try: + if after_id is not None: + win.after_cancel(after_id) + except Exception: + pass + try: + win.destroy() + except Exception: + pass + + win.protocol("WM_DELETE_WINDOW", on_close) + + def update_plot(): + nonlocal cpu_data, mem_data, net_data, prev_net, after_id + # Si la ventana se cerró, terminar el bucle de actualización + try: + if not win.winfo_exists(): + return + except Exception: + return + cpu = psutil.cpu_percent(interval=None) + mem = psutil.virtual_memory().percent + cur_net = psutil.net_io_counters() + # bytes per second -> KB/s + sent = (cur_net.bytes_sent - prev_net.bytes_sent) / 1024.0 + recv = (cur_net.bytes_recv - prev_net.bytes_recv) / 1024.0 + prev_net = cur_net + net_kb = (sent + recv) / 2.0 + + cpu_data = cpu_data[1:]+[cpu] + mem_data = mem_data[1:]+[mem] + net_data = net_data[1:]+[net_kb] + + line_cpu.set_ydata(cpu_data) + line_mem.set_ydata(mem_data) + line_net.set_ydata(net_data) + try: + canvas.draw() + except Exception: + # Si el canvas fue destruido, salir + return + try: + if win.winfo_exists(): + # guardar id para poder cancelarlo en on_close + nonlocal after_id + after_id = win.after(1000, update_plot) + except Exception: + return + + update_plot() + + +def open_text_editor(): + win = tk.Toplevel(root) + win.title("Editor de texto") + txt = tk.Text(win, wrap="word") + txt.pack(fill="both", expand=True) + + def save(): + path = fd.asksaveasfilename(defaultextension=".txt") + if path: + with open(path, "w", encoding="utf-8") as f: + f.write(txt.get("1.0", "end-1c")) + mb.showinfo("Guardado", "Archivo guardado") + + def open_file(): + path = fd.askopenfilename(filetypes=[("Text","*.txt;*.py;*.md"), ("All","*")]) + if path: + if os.path.isdir(path): + mb.showwarning("Abrir", "Selecciona un archivo, no una carpeta") + return + try: + with open(path, "r", encoding="utf-8") as f: + txt.delete("1.0", "end") + txt.insert("1.0", f.read()) + except Exception as e: + mb.showerror("Error", f"No se pudo leer el archivo:\n{e}") + + btns = tk.Frame(win) + ttk.Button(btns, text="Abrir", command=open_file, style="Secondary.TButton").pack(side="left") + ttk.Button(btns, text="Guardar", command=save, style="Accent.TButton").pack(side="left") + btns.pack() + + +def scrape_url(): + if not HAS_REQUESTS: + mb.showwarning("Dependencia", "requests/bs4 no están disponibles. Instálalos con pip install requests beautifulsoup4") + return + url = sd.askstring("Scraping", "Introduce la URL a scrapear:") + if not url: + return + try: + r = requests.get(url, timeout=10) + r.raise_for_status() + soup = BeautifulSoup(r.text, "html.parser") + # eliminar scripts, styles y noscript + for tag in soup(["script", "style", "noscript"]): + tag.decompose() + # intentar obtener título y meta description + title = soup.title.string.strip() if soup.title and soup.title.string else "" + meta_desc = "" + md = soup.find("meta", attrs={"name": "description"}) + if md and md.get("content"): + meta_desc = md.get("content").strip() + + # extraer texto visible, limpiar espacios + raw_text = soup.get_text(separator="\n") + # colapsar líneas en exceso y espacios + lines = [ln.strip() for ln in raw_text.splitlines()] + cleaned = "\n".join([ln for ln in lines if ln]) + + # preparar carpeta de salida `scrapping` en el directorio del script + base_dir = os.path.abspath(os.path.dirname(__file__)) + out_dir = os.path.join(base_dir, "scrapping") + try: + os.makedirs(out_dir, exist_ok=True) + except Exception: + pass + + # construir nombre de archivo seguro + from urllib.parse import urlparse + parsed = urlparse(url) + netloc = parsed.netloc or parsed.path.replace("/", "_") + safe_netloc = "".join([c if c.isalnum() else "_" for c in netloc])[:80] + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + txt_name = f"scrape_{safe_netloc}_{timestamp}.txt" + html_name = f"scrape_{safe_netloc}_{timestamp}.html" + txt_path = os.path.join(out_dir, txt_name) + html_path = os.path.join(out_dir, html_name) + + # escribir archivos + header = f"URL: {url}\nTitle: {title}\nMeta-Description: {meta_desc}\nTimestamp: {timestamp}\n\n" + try: + with open(txt_path, "w", encoding="utf-8") as f: + f.write(header) + f.write(cleaned) + except Exception as e: + mb.showwarning("Advertencia", f"No se pudo guardar el fichero txt:\n{e}") + + try: + with open(html_path, "w", encoding="utf-8") as f: + f.write(r.text) + except Exception: + pass + + # mostrar resultado reducido en una ventana y notificar fichero guardado + win = tk.Toplevel(root) + win.title(f"Scrape: {url}") + t = tk.Text(win, wrap="word") + t.insert("1.0", header + cleaned[:20000]) + t.pack(fill="both", expand=True) + + try: + mb.showinfo("Guardado", f"Contenido scrapado guardado en:\n{txt_path}") + except Exception: + pass + except Exception as e: + mb.showerror("Error", f"Falló scraping:\n{e}") + + +def fetch_weather_xabia(): + """Consulta la API de OpenWeatherMap para obtener el tiempo en Jávea (Alicante). + Pide al usuario la API key (se puede obtener en https://home.openweathermap.org/api_keys). + Actualiza la etiqueta central `center_status` con temperatura y muestra un cuadro informativo. + """ + # comprobar dependencia + if not HAS_REQUESTS: + mb.showwarning("Dependencia", "requests no está instalado. Instálalo con pip install requests") + return + + # Ruta para guardar la API key de forma persistente + cfg_dir = os.path.join(os.path.expanduser("~"), ".config", "proyecto") + key_file = os.path.join(cfg_dir, "openweather.key") + + api_key = None + # si existe fichero con key, usarla + try: + if os.path.exists(key_file): + with open(key_file, "r", encoding="utf-8") as fk: + k = fk.read().strip() + if k: + api_key = k + except Exception: + api_key = None + + # si no había key persistida, pedirla y guardarla + if not api_key: + api_key = sd.askstring("OpenWeatherMap API", "Introduce tu API Key de OpenWeatherMap:") + if not api_key: + return + try: + os.makedirs(cfg_dir, exist_ok=True) + with open(key_file, "w", encoding="utf-8") as fk: + fk.write(api_key.strip()) + except Exception: + # no crítico: continuar sin guardar + pass + + # Usar lat/lon para Jávea (Xàbia): lat=38.789166, lon=0.163055 + lat = 38.789166 + lon = 0.163055 + try: + url = "https://api.openweathermap.org/data/2.5/weather" + params = {"lat": lat, "lon": lon, "appid": api_key, "units": "metric", "lang": "es"} + r = requests.get(url, params=params, timeout=10) + r.raise_for_status() + data = r.json() + + temp = data.get("main", {}).get("temp") + desc = data.get("weather", [{}])[0].get("description", "") + humidity = data.get("main", {}).get("humidity") + wind = data.get("wind", {}).get("speed") + + info = f"Tiempo en Jávea, Alicante:\nTemperatura: {temp} °C\nCondición: {desc}\nHumedad: {humidity}%\nViento: {wind} m/s" + try: + mb.showinfo("Tiempo - Jávea", info) + except Exception: + pass + try: + center_status.config(text=f"Jávea: {temp}°C, {desc}") + except Exception: + pass + except requests.HTTPError as e: + # Manejo específico para 401 (Unauthorized) + try: + resp = getattr(e, 'response', None) + if resp is not None and resp.status_code == 401: + ans = mb.askyesno("Autenticación", "La API key no es válida (401 Unauthorized).\n¿Quieres borrar la key guardada y volver a introducirla?") + if ans: + try: + if os.path.exists(key_file): + os.remove(key_file) + except Exception: + pass + # reintentar: llamar recursivamente para pedir nueva key + try: + fetch_weather_xabia() + except Exception: + pass + else: + try: + mb.showerror("Error", "API key inválida. Revisa tu key en OpenWeatherMap.") + except Exception: + pass + return + except Exception: + pass + try: + mb.showerror("Error", f"Error al obtener datos: {e}") + except Exception: + pass + except Exception as e: + try: + mb.showerror("Error", f"Falló la consulta:\n{e}") + except Exception: + pass + + +def clear_openweather_key(): + """Borra la API key guardada de OpenWeather (si existe).""" + cfg_dir = os.path.join(os.path.expanduser("~"), ".config", "proyecto") + key_file = os.path.join(cfg_dir, "openweather.key") + try: + if os.path.exists(key_file): + os.remove(key_file) + mb.showinfo("Key eliminada", f"Se ha eliminado: {key_file}") + else: + mb.showinfo("Key", "No había ninguna key guardada") + except Exception as e: + try: + mb.showerror("Error", f"No se pudo borrar la key:\n{e}") + except Exception: + pass + + +def play_music_file(): + if not HAS_PYGAME: + # si pygame no está disponible, usaremos afplay como fallback + pass + path = fd.askopenfilename(filetypes=[("Audio","*.mp3;*.wav;*.midi;*.mid"), ("All","*")]) + if not path: + return + + def _play_with_pygame(p): + global music_playing, music_current + try: + with music_lock: + # detener cualquier reproducción previa + try: + pygame.mixer.music.stop() + except Exception: + pass + pygame.mixer.music.load(p) + pygame.mixer.music.play(-1) + music_current = p + music_playing = True + except Exception as e: + mb.showerror("Error", f"No se pudo reproducir con pygame:\n{e}") + + def _play_with_afplay(p): + global music_process, music_playing, music_current + try: + # detener proceso anterior + with music_lock: + if music_process is not None: + try: + music_process.kill() + except Exception: + pass + # iniciar afplay en background + music_process = subprocess.Popen(["afplay", p]) + music_current = p + music_playing = True + except Exception as e: + mb.showerror("Error", f"No se pudo reproducir con afplay:\n{e}") + + # arrancar en hilo para no bloquear la UI + def runner(p): + if HAS_PYGAME: + _play_with_pygame(p) + else: + _play_with_afplay(p) + + threading.Thread(target=runner, args=(path,), daemon=True).start() + + +def stop_music(): + """Detiene la reproducción iniciada por `play_music_file` (pygame o afplay).""" + global music_process, music_playing, music_current + with music_lock: + if HAS_PYGAME: + try: + pygame.mixer.music.stop() + except Exception: + pass + if music_process is not None: + try: + music_process.kill() + except Exception: + pass + music_process = None + music_playing = False + music_current = None + + +def set_alarm_minutes(): + mins = sd.askinteger("Alarma", "Avisar en cuántos minutos?", minvalue=1, maxvalue=1440) + if not mins: + return + + # si ya hay una alarma, cancelarla antes + try: + if alarm_control.get("event") is not None: + try: + alarm_control["event"].set() + except Exception: + pass + except Exception: + pass + + ev = Event() + end_ts = time.time() + mins * 60 + alarm_control["event"] = ev + alarm_control["end_ts"] = end_ts + + def alarm_worker(): + try: + while True: + if ev.is_set(): + # cancelada + try: + root.after(0, alarm_countdown_label.config, {"text": "Alarma cancelada"}) + except Exception: + pass + break + now_ts = time.time() + remaining = int(end_ts - now_ts) + if remaining <= 0: + # sonar alarma + try: + sound_path = "/System/Library/Sounds/Glass.aiff" + if HAS_PYGAME: + try: + s = pygame.mixer.Sound(sound_path) + s.play() + except Exception: + root.bell() + else: + subprocess.Popen(["afplay", sound_path]) + except Exception: + try: + root.bell() + except Exception: + pass + try: + root.after(0, mb.showinfo, "Alarma", f"Pasaron {mins} minutos") + except Exception: + pass + try: + root.after(0, alarm_countdown_label.config, {"text": "No hay alarma programada"}) + except Exception: + pass + break + + # actualizar etiqueta en hilo principal + try: + h = remaining // 3600 + mnt = (remaining % 3600) // 60 + s = remaining % 60 + text = f"Cuenta atrás: {h:02d}:{mnt:02d}:{s:02d}" + root.after(0, alarm_countdown_label.config, {"text": text}) + except Exception: + pass + + time.sleep(1) + finally: + # limpiar control + try: + alarm_control["event"] = None + alarm_control["end_ts"] = None + except Exception: + pass + + t = threading.Thread(target=alarm_worker, daemon=True) + alarm_control["thread"] = t + t.start() + + +def cancel_alarm(): + """Cancela la alarma programada (si existe).""" + try: + ev = alarm_control.get("event") + if ev is not None: + try: + ev.set() + except Exception: + pass + try: + alarm_countdown_label.config(text="Alarma cancelada") + except Exception: + pass + alarm_control["event"] = None + alarm_control["thread"] = None + alarm_control["end_ts"] = None + except Exception: + pass + + +def open_game_race(parent_canvas=None, num_racers=4, speed_mult=1.0): + """Ejecuta la carrera de camellos en un canvas dado. + Si no se proporciona canvas, abre un Toplevel (compatibilidad antigua). + num_racers: número de corredores + speed_mult: multiplicador de velocidad (>=0.1) + """ + if parent_canvas is None: + win = tk.Toplevel(root) + win.title("Carrera de camellos") + canvas = tk.Canvas(win, width=600, height=200, bg="white") + canvas.pack() + # si se crea un Toplevel, asegurar limpieza cuando se cierre + def _on_win_close(): + try: + stop_event.set() + except Exception: + pass + try: + win.destroy() + except Exception: + pass + # provisional — stop_event aún no creado; lo conectaremos más abajo estableciendo protocolo después + else: + canvas = parent_canvas + try: + canvas.delete("all") + canvas.config(bg="white") + except Exception: + # si el canvas no existe o fue destruido, no continuar + return + + # Calcular línea de meta en función del tamaño del canvas + try: + finish = canvas.winfo_width() - 50 + except Exception: + finish = 550 + if finish < 200: + finish = 550 + + camels = [] + colors = ["red", "blue", "green", "orange", "purple", "cyan", "magenta", "yellow"] + # limitar número de corredores + try: + n = max(1, min(int(num_racers), 12)) + except Exception: + n = 4 + for i in range(n): + y = 20 + i * 30 + color = colors[i % len(colors)] + rect = canvas.create_rectangle(10, y, 60, y + 25, fill=color) + camels.append(rect) + + # Control para anunciar ganador una sola vez + winner_lock = threading.Lock() + winner = {"index": None} + + lock = threading.Lock() + # evento para detener esta carrera + stop_event = Event() + race_stop_events[id(canvas)] = stop_event + + # si se creó win arriba, conectar el cierre a stop_event + try: + if 'win' in locals(): + win.protocol("WM_DELETE_WINDOW", _on_win_close) + except Exception: + pass + + def racer(item, idx): + while True: + if stop_event.is_set(): + return + try: + with lock: + try: + coords = canvas.coords(item) + except tk.TclError: + return + if not coords: + return + x1, y1, x2, y2 = coords + if x2 >= finish: + # Si aún no hay ganador, anunciarlo y resaltar + with winner_lock: + if winner["index"] is None: + winner["index"] = idx + 1 + try: + # resaltar ganador en dorado + root.after(0, lambda it=item: canvas.itemconfig(it, fill="#FFD700")) + except Exception: + pass + try: + root.after(0, mb.showinfo, "Ganador", f"¡Camello #{winner['index']} ha ganado!") + except Exception: + pass + # detener el resto de corredores + try: + stop_event.set() + except Exception: + pass + return + max_step = max(1, int(10 * float(speed_mult))) + step = random.randint(1, max_step) + try: + canvas.move(item, step, 0) + except tk.TclError: + return + except Exception: + return + time.sleep(random.uniform(0.05, 0.2)) + + for idx, r in enumerate(camels): + threading.Thread(target=racer, args=(r, idx), daemon=True).start() + + # Lanzar un watcher que elimina el evento cuando la carrera termina + def _watcher(): + try: + while True: + if stop_event.is_set(): + break + all_done = True + with lock: + for item in camels: + try: + coords = canvas.coords(item) + except tk.TclError: + # canvas destroyed -> stop + stop_event.set() + all_done = True + break + if coords and coords[2] < finish: + all_done = False + break + if all_done: + break + time.sleep(0.5) + finally: + try: + race_stop_events.pop(id(canvas), None) + except Exception: + pass + + threading.Thread(target=_watcher, daemon=True).start() + + +def launch_app(path): + """Abrir una aplicación en macOS usando `open` en un hilo separado.""" + def _run(): + if not os.path.exists(path): + # intentar con el nombre de la app si se pasó un nombre + try: + subprocess.run(["open", "-a", path], check=True) + return + except Exception as e: + mb.showerror("Error", f"No se encontró la aplicación:\n{path}\n{e}") + return + try: + subprocess.run(["open", path], check=True) + except Exception as e: + try: + subprocess.run(["open", "-a", path], check=True) + except Exception as e2: + mb.showerror("Error", f"No se pudo abrir la aplicación:\n{e}\n{e2}") + + threading.Thread(target=_run, daemon=True).start() + + +# Crear la ventana principal +root = tk.Tk() +root.title("Ventana Responsive") +root.geometry("1200x700") # Tamaño inicial (más ancho) + +# Tema y paleta básica +PALETTE = { + "bg_main": "#f5f7fa", + "sidebar": "#eef3f8", + "panel": "#ffffff", + "accent": "#2b8bd6", + "muted": "#7a8a99" +} +FONT_TITLE = ("Helvetica", 11, "bold") +FONT_NORMAL = ("Helvetica", 10) + +root.configure(bg=PALETTE["bg_main"]) +_style = ttk.Style(root) +try: + _style.theme_use("clam") +except Exception: + pass +_style.configure("Accent.TButton", background=PALETTE["accent"], foreground="white", font=FONT_NORMAL, padding=6) +_style.map("Accent.TButton", background=[('active', '#1e68b8')]) +_style.configure("Secondary.TButton", background="#eef6fb", foreground=PALETTE["accent"], font=FONT_NORMAL, padding=6) +_style.map("Secondary.TButton", background=[('active', '#e0f0ff')]) +_style.configure("TNotebook", background=PALETTE["bg_main"], tabposition='n') +_style.configure("TFrame", background=PALETTE["panel"]) + + +# Configurar la ventana principal para que sea responsive +root.columnconfigure(0, weight=0) # Columna izquierda, tamaño fijo +root.columnconfigure(1, weight=1) # Columna central, tamaño variable +root.columnconfigure(2, weight=0) # Columna derecha, tamaño fijo +root.rowconfigure(0, weight=1) # Fila principal, tamaño variable +root.rowconfigure(1, weight=0) # Barra de estado, tamaño fijo + +# Crear el menú superior +menu_bar = Menu(root) + +file_menu = Menu(menu_bar, tearoff=0) +file_menu.add_command(label="Nuevo") +file_menu.add_command(label="Abrir") +file_menu.add_separator() +file_menu.add_command(label="Salir", command=root.quit) + +edit_menu = Menu(menu_bar, tearoff=0) +edit_menu.add_command(label="Copiar") +edit_menu.add_command(label="Pegar") + +help_menu = Menu(menu_bar, tearoff=0) +help_menu.add_command(label="Acerca de") + +menu_bar.add_cascade(label="Archivo", menu=file_menu) +menu_bar.add_cascade(label="Editar", menu=edit_menu) +menu_bar.add_cascade(label="Ayuda", menu=help_menu) + +root.config(menu=menu_bar) + +# Crear los frames laterales y el central +frame_izquierdo = tk.Frame(root, bg=PALETTE["sidebar"], width=220, highlightthickness=0) +frame_central = tk.Frame(root, bg=PALETTE["bg_main"]) +frame_derecho = tk.Frame(root, bg=PALETTE["sidebar"], width=260, highlightthickness=0) + +# Colocar los frames laterales y el central +frame_izquierdo.grid(row=0, column=0, sticky="ns") +frame_central.grid(row=0, column=1, sticky="nsew") +frame_derecho.grid(row=0, column=2, sticky="ns") + +# Configurar los tamaños fijos de los frames laterales +frame_izquierdo.grid_propagate(False) +frame_derecho.grid_propagate(False) + +# --- Contenido del sidebar izquierdo (secciones y botones) --- +left_title = tk.Label(frame_izquierdo, text="", bg=PALETTE["sidebar"]) +left_title.pack(pady=10) + +sec_acciones = tk.Label(frame_izquierdo, text="Acciones", bg=PALETTE["panel"], font=FONT_TITLE, anchor="w", padx=8) +sec_acciones.pack(fill="x", padx=8, pady=(8,2)) + +btn_extraer = ttk.Button(frame_izquierdo, text="Extraer datos", width=18, style="Secondary.TButton") +btn_navegar = ttk.Button(frame_izquierdo, text="Navegar", width=18, style="Secondary.TButton") +btn_buscar = ttk.Button(frame_izquierdo, text="Buscar API Google", width=18, style="Secondary.TButton") +btn_extraer.pack(pady=6, padx=8, fill='x') +btn_navegar.pack(pady=6, padx=8, fill='x') +btn_buscar.pack(pady=6, padx=8, fill='x') + +sec_apps = tk.Label(frame_izquierdo, text="Aplicaciones", bg=PALETTE["panel"], font=FONT_TITLE, anchor="w", padx=8) +sec_apps.pack(fill="x", padx=8, pady=(12,6)) + +btn_vscode = ttk.Button(frame_izquierdo, text="Visual Code", width=18, style="Accent.TButton") +btn_app2 = ttk.Button(frame_izquierdo, text="App2", width=18, style="Secondary.TButton") +btn_app3 = ttk.Button(frame_izquierdo, text="App3", width=18, style="Secondary.TButton") +btn_vscode.pack(pady=6, padx=8, fill='x') +btn_app2.pack(pady=6, padx=8, fill='x') +btn_app3.pack(pady=6, padx=8, fill='x') + +sec_batch = tk.Label(frame_izquierdo, text="Procesos batch", bg=PALETTE["panel"], font=FONT_TITLE, anchor="w", padx=8) +sec_batch.pack(fill="x", padx=8, pady=(12,6)) + +btn_backup = ttk.Button(frame_izquierdo, text="Copias de seguridad", width=18, style="Secondary.TButton") +btn_backup.pack(pady=6, padx=8, fill='x') +# --- Contenido del sidebar derecho (chat y lista de alumnos) --- +chat_title = tk.Label(frame_derecho, text="Chat", font=("Helvetica", 14, "bold"), bg=PALETTE["sidebar"]) +chat_title.pack(pady=(8,8)) + +msg_label = tk.Label(frame_derecho, text="Mensaje", bg=PALETTE["sidebar"], font=FONT_NORMAL) +msg_label.pack(padx=8, anchor="w") + +msg_text = tk.Text(frame_derecho, height=6, width=26, bd=0, relief="flat") +msg_text.pack(padx=8, pady=(6,8), fill="x") + +send_btn = ttk.Button(frame_derecho, text="Enviar", style="Accent.TButton") +send_btn.pack(padx=8, pady=(0,12)) + +alumnos_label = tk.Label(frame_derecho, text="Alumnos", bg=PALETTE["sidebar"], font=FONT_TITLE) +alumnos_label.pack(padx=8, anchor="w") + +# Frame con scrollbar para la lista de alumnos +alumnos_frame = tk.Frame(frame_derecho) +alumnos_frame.pack(fill="both", expand=True, padx=8, pady=6) + +canvas = tk.Canvas(alumnos_frame, borderwidth=0, highlightthickness=0, bg="white") +scrollbar = tk.Scrollbar(alumnos_frame, orient="vertical", command=canvas.yview) +inner = tk.Frame(canvas, bg="white") +inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) +canvas.create_window((0, 0), window=inner, anchor="nw") +canvas.configure(yscrollcommand=scrollbar.set) +canvas.pack(side="left", fill="both", expand=True) +scrollbar.pack(side="right", fill="y") + +# Añadir algunos alumnos de ejemplo +for n in range(1, 6): + a_frame = tk.Frame(inner, bg="white", bd=1, relief="groove") + tk.Label(a_frame, text=f"Alumno {n}", font=("Helvetica", 12, "bold"), bg="white").pack(anchor="w") + tk.Label(a_frame, text="Lorem ipsum dolor sit amet, consectetur...", bg="white", wraplength=160, justify="left").pack(anchor="w", pady=(2,6)) + a_frame.pack(fill="x", pady=4) + + + +music_label = tk.Label(frame_derecho, text="Reproductor música", bg="#dcdcdc") +music_label.pack(fill="x", padx=8, pady=(6,8)) + +# Botones / comandos vinculados +btn_navegar.config(command=launch_browser_prompt) +# El botón de copias ahora pide un archivo o carpeta y lo copia a ./backup +btn_backup.config(command=backup_ui) +# Abrir Visual Studio Code (ruta absoluta en macOS) +btn_vscode.config(command=lambda: launch_app("/Applications/Visual Studio Code.app")) +btn_app2.config(command=open_resource_window) +btn_app3.config(command=open_game_race) +btn_extraer.config(command=scrape_url) +btn_buscar.config(command=fetch_weather_xabia) + # refresh button removed (was duplicated per alumno) + +# Los controles de alarma y reproducción de música están disponibles en la pestaña "Enlaces" + +# Enviar mensaje (simulado) +def send_message(): + text = msg_text.get("1.0", "end-1c").strip() + if not text: + mb.showwarning("Mensaje", "El mensaje está vacío") + return + mb.showinfo("Mensaje", "Mensaje enviado (simulado)") + msg_text.delete("1.0", "end") + +send_btn.config(command=send_message) + +# Dividir el frame central en dos partes (superior variable e inferior fija) +frame_central.rowconfigure(0, weight=1) # Parte superior, tamaño variable +frame_central.rowconfigure(1, weight=0) # Parte inferior, tamaño fijo +frame_central.columnconfigure(0, weight=1) # Ocupa toda la anchura + +# Crear subframes dentro del frame central +frame_superior = tk.Frame(frame_central, bg="lightyellow") +frame_inferior = tk.Frame(frame_central, bg="lightgray", height=100) + +# Colocar los subframes dentro del frame central +frame_superior.grid(row=0, column=0, sticky="nsew") +frame_inferior.grid(row=1, column=0, sticky="ew") + +# Fijar el tamaño de la parte inferior +frame_inferior.grid_propagate(False) + +# Añadir texto informativo en la parte inferior central +info_label = tk.Label(frame_inferior, text="Panel para notas informativas y mensajes sobre la ejecución de los hilos.", + bg=PALETTE["panel"], anchor="w", justify="left", padx=12, font=FONT_NORMAL) +info_label.pack(fill="both", expand=True, padx=8, pady=8) + +# Crear la barra de estado como contenedor (Frame) +barra_estado = tk.Frame(root, bg="lightgray") +barra_estado.grid(row=1, column=0, columnspan=3, sticky="ew") + +# Notebook para las pestañas +style = ttk.Style() +style.configure("CustomNotebook.TNotebook.Tab", font=("Arial", 12, "bold")) +notebook = ttk.Notebook(frame_superior, style="CustomNotebook.TNotebook") +notebook.pack(fill="both", expand=True, padx=6, pady=6) + +# Crear seis solapas con nombres definidos +tab_resultados = ttk.Frame(notebook) +tab_navegador = ttk.Frame(notebook) +tab_correos = ttk.Frame(notebook) +tab_tareas = ttk.Frame(notebook) +tab_alarmas = ttk.Frame(notebook) +tab_enlaces = ttk.Frame(notebook) + +notebook.add(tab_resultados, text="Resultados", padding=8) +notebook.add(tab_navegador, text="Navegador", padding=8) +notebook.add(tab_correos, text="Correos", padding=8) +notebook.add(tab_tareas, text="Tareas", padding=8) +notebook.add(tab_alarmas, text="Alarmas", padding=8) +notebook.add(tab_enlaces, text="Enlaces", padding=8) + +# --- Contenido básico de cada solapa --- +# Resultados: canvas del juego y botón para iniciar la carrera +res_top = tk.Frame(tab_resultados) +res_top.pack(fill="both", expand=True) +res_controls = tk.Frame(tab_resultados, height=40) +res_controls.pack(fill="x") +res_canvas = tk.Canvas(res_top, width=800, height=300, bg="white") +res_canvas.pack(fill="both", expand=True, padx=8, pady=8) +# Controles: iniciar, número de corredores, velocidad y detener +start_race_btn = ttk.Button(res_controls, text="Iniciar Carrera", style="Accent.TButton") +start_race_btn.pack(side="left", padx=8, pady=6) +tk.Label(res_controls, text="Corredores:").pack(side="left", padx=(10,2)) +num_spin = tk.Spinbox(res_controls, from_=1, to=12, width=4) +num_spin.pack(side="left", padx=2) +tk.Label(res_controls, text="Velocidad:").pack(side="left", padx=(10,2)) +speed_scale = tk.Scale(res_controls, from_=0.5, to=3.0, resolution=0.1, orient="horizontal", length=140) +speed_scale.set(1.0) +speed_scale.pack(side="left", padx=2) +stop_race_btn = ttk.Button(res_controls, text="Detener Carrera", style="Secondary.TButton") +stop_race_btn.pack(side="left", padx=8) + +# Enlazar el botón para ejecutar la carrera dentro del canvas de la solapa Resultados +def _start_from_ui(): + try: + n = int(num_spin.get()) + except Exception: + n = 4 + try: + sp = float(speed_scale.get()) + except Exception: + sp = 1.0 + open_game_race(res_canvas, num_racers=n, speed_mult=sp) + +def _stop_from_ui(): + ev = race_stop_events.get(id(res_canvas)) + if ev is not None: + try: + ev.set() + except Exception: + pass + try: + res_canvas.delete("all") + except Exception: + pass + +start_race_btn.config(command=_start_from_ui) +stop_race_btn.config(command=_stop_from_ui) + +# Navegador: entrada de URL y botón +nav_frame = tk.Frame(tab_navegador) +nav_frame.pack(fill="both", expand=True, padx=8, pady=8) +url_entry = tk.Entry(nav_frame) +url_entry.insert(0, "https://www.google.com") +url_entry.pack(fill="x", side="left", expand=True, padx=(0,8)) +open_url_btn = ttk.Button(nav_frame, text="Abrir", command=lambda: threading.Thread(target=launch_browser, args=(url_entry.get(),), daemon=True).start(), style="Accent.TButton") +open_url_btn.pack(side="right") + +# Correos: cuadro de chat simple (simulado) +cor_frame = tk.Frame(tab_correos) +cor_frame.pack(fill="both", expand=True, padx=8, pady=8) +cor_msg_text = tk.Text(cor_frame, height=12) +cor_msg_text.pack(fill="both", expand=True) +cor_send_btn = ttk.Button(cor_frame, text="Enviar", width=12, style="Accent.TButton") +cor_send_btn.pack(pady=(6,0)) + +def correos_send(): + text = cor_msg_text.get("1.0", "end-1c").strip() + if not text: + mb.showwarning("Mensaje", "El mensaje está vacío") + return + mb.showinfo("Mensaje", "Mensaje enviado (simulado)") + cor_msg_text.delete("1.0", "end") + +cor_send_btn.config(command=correos_send) + +# Tareas: editor simple embebido +task_frame = tk.Frame(tab_tareas) +task_frame.pack(fill="both", expand=True, padx=8, pady=8) +task_text = tk.Text(task_frame, wrap="word") +task_text.pack(fill="both", expand=True) +task_btns = tk.Frame(tab_tareas) +task_btns.pack(fill="x") + +def task_open(): + path = fd.askopenfilename(filetypes=[("Text","*.txt;*.py;*.md"), ("All","*")]) + if not path: + return + if os.path.isdir(path): + mb.showwarning("Abrir", "Selecciona un archivo, no una carpeta") + return + try: + with open(path, "r", encoding="utf-8") as f: + task_text.delete("1.0", "end") + task_text.insert("1.0", f.read()) + except Exception as e: + mb.showerror("Error", f"No se pudo leer el archivo:\n{e}") + +def task_save(): + path = fd.asksaveasfilename(defaultextension=".txt") + if not path: + return + try: + with open(path, "w", encoding="utf-8") as f: + f.write(task_text.get("1.0", "end-1c")) + mb.showinfo("Guardado", "Archivo guardado") + except Exception as e: + mb.showerror("Error", f"No se pudo guardar el archivo:\n{e}") + + ttk.Button(task_btns, text="Abrir", command=task_open, style="Secondary.TButton").pack(side="left", padx=4, pady=6) + ttk.Button(task_btns, text="Guardar", command=task_save, style="Accent.TButton").pack(side="left", padx=4, pady=6) + +# Alarmas: usar set_alarm_minutes (ya existente) +alarm_frame = tk.Frame(tab_alarmas) +alarm_frame.pack(fill="both", expand=True, padx=8, pady=8) +ttk.Button(alarm_frame, text="Programar alarma", command=set_alarm_minutes, style="Accent.TButton").pack(pady=8) + +# Label de cuenta regresiva y botón cancelar +alarm_countdown_label = tk.Label(alarm_frame, text="No hay alarma programada", font=FONT_TITLE, bg=PALETTE["panel"], fg=PALETTE["muted"], padx=8, pady=6) +alarm_countdown_label.pack(pady=(6,8), fill="x") +ttk.Button(alarm_frame, text="Cancelar alarma", command=lambda: threading.Thread(target=lambda: cancel_alarm(), daemon=True).start(), style="Secondary.TButton").pack() + +# Enlaces: botones para abrir apps y utilidades +links_frame = tk.Frame(tab_enlaces) +links_frame.pack(fill="both", expand=True, padx=8, pady=8) +ttk.Button(links_frame, text="Abrir Visual Studio Code", command=lambda: launch_app("/Applications/Visual Studio Code.app"), style="Accent.TButton").pack(fill="x", pady=4) +ttk.Button(links_frame, text="Abrir Spotify", command=lambda: launch_app("/Applications/Spotify.app"), style="Secondary.TButton").pack(fill="x", pady=4) +ttk.Button(links_frame, text="Mostrar recursos (matplotlib)", command=open_resource_window, style="Secondary.TButton").pack(fill="x", pady=4) +ttk.Button(links_frame, text="Reproducir música (archivo)", command=play_music_file, style="Secondary.TButton").pack(fill="x", pady=4) +ttk.Button(links_frame, text="Detener música", command=stop_music, style="Secondary.TButton").pack(fill="x", pady=4) +ttk.Button(links_frame, text="Borrar OpenWeather Key", command=clear_openweather_key, style="Secondary.TButton").pack(fill="x", pady=4) + +# Barra de estado +# Dividir la barra de estado en 4 labels + + +# Usar pack para alinear los labels horizontalmente + + + +# Secciones en la barra de estado: izquierda, centro y derecha +left_status = tk.Label(barra_estado, text="Correos sin leer 🔄", bg="#f0f0f0", anchor="w", padx=8) +center_status = tk.Label(barra_estado, text="Temperatura local: -- °C", bg="#f0f0f0", anchor="center") +label_fecha_hora = tk.Label(barra_estado, text="Cargando fecha...", font=("Helvetica", 12), bd=1, fg="blue", relief="sunken", anchor="e", padx=10) + +left_status.pack(side="left", fill="x", expand=True) +center_status.pack(side="left", fill="x", expand=True) +label_fecha_hora.pack(side="right") + +# Iniciar hilo para actualizar la fecha/hora +update_thread = threading.Thread(target=update_time, args=(label_fecha_hora,)) +update_thread.daemon = True +update_thread.start() + + +# Hilo que monitoriza tráfico de red y actualiza la etiqueta central en KB/s +def network_monitor(label_widget): + try: + prev = psutil.net_io_counters() + except Exception: + return + while True: + time.sleep(1) + cur = psutil.net_io_counters() + sent = (cur.bytes_sent - prev.bytes_sent) / 1024.0 + recv = (cur.bytes_recv - prev.bytes_recv) / 1024.0 + prev = cur + text = f"Tráfico - In: {recv:.1f} KB/s Out: {sent:.1f} KB/s" + try: + label_widget.after(0, label_widget.config, {"text": text}) + except Exception: + break + + +net_thread = threading.Thread(target=network_monitor, args=(center_status,)) +net_thread.daemon = True +net_thread.start() + +# Ejecución de la aplicación +root.mainloop() \ No newline at end of file