proyecto-global-psp/vista/chat/chat_selector.py

421 lines
15 KiB
Python

import tkinter as tk
from tkinter import ttk, messagebox
import threading
from vista.config import *
from logica.red.cliente import descubrir_servidores
from logica.red.selector import obtener_ip_local
from vista.chat.chat_servidor import ChatServidorPanel
from vista.chat.chat_cliente import ChatClientePanel
_DONUT_SIZE = 72
_DONUT_RADIUS = 26
_DONUT_GROSOR = 9
_DONUT_COLOR = COLOR_ACCION
_DONUT_TRACK = "#d0d0d0"
_DONUT_STEP = 9 # grados por frame
_DONUT_MS = 25 # ms entre frames
class ChatSelectorPanel(ttk.Frame):
"""Panel inicial que permite elegir entre servidor o cliente."""
def __init__(self, parent_notebook, root, *args, **kwargs):
super().__init__(parent_notebook, *args, **kwargs)
self.root = root
self.parent_notebook = parent_notebook
self.panel_activo = None
self._busqueda_cancelada = False
self._stop_event = None
self._donut_animating = False
self._donut_angle = 0
self.servidores_encontrados = []
self._ultimo_visto = {} # (ip, puerto) -> timestamp
self._frames_servidores = {} # (ip, puerto) -> ttk.Frame
self._frame_servidores = None
self._lbl_contador = None
self._lbl_buscando = None
self._canvas_donut = None
self._btn_cancelar = None
self.crear_selector()
# ------------------------------------------------------------------
# Pantalla principal
# ------------------------------------------------------------------
def crear_selector(self):
self.selector_frame = ttk.Frame(self, padding=30, style='TFrame')
self.selector_frame.pack(expand=True, fill="both")
ttk.Label(
self.selector_frame, text="Chat en Red",
font=FUENTE_TITULO
).pack(pady=(20, 5))
ip = obtener_ip_local()
ttk.Label(
self.selector_frame, text=f"IP local: {ip}",
font=FUENTE_NOTA
).pack(pady=(0, 20))
frame_botones = ttk.Frame(self.selector_frame, style='TFrame')
frame_botones.pack(pady=10)
ttk.Button(
frame_botones, text="Crear Servidor",
command=self.iniciar_servidor, style='Action.TButton'
).pack(side="left", padx=10)
ttk.Button(
frame_botones, text="Conectar como Cliente",
command=self.iniciar_busqueda_cliente, style='Action.TButton'
).pack(side="left", padx=10)
self.frame_cliente = ttk.Frame(self.selector_frame, style='TFrame')
# ------------------------------------------------------------------
# Servidor
# ------------------------------------------------------------------
def iniciar_servidor(self):
self.selector_frame.destroy()
self.panel_activo = ChatServidorPanel(self, self.root)
self.panel_activo.pack(expand=True, fill="both")
# ------------------------------------------------------------------
# Busqueda de servidores — entrada
# ------------------------------------------------------------------
def iniciar_busqueda_cliente(self):
self._busqueda_cancelada = False
self.servidores_encontrados = []
self._ultimo_visto = {}
self._frames_servidores = {}
self._stop_event = threading.Event()
for widget in self.frame_cliente.winfo_children():
widget.destroy()
self.frame_cliente.pack(pady=20, fill="x")
# Donut canvas
self._canvas_donut = tk.Canvas(
self.frame_cliente,
width=_DONUT_SIZE, height=_DONUT_SIZE,
bg=COLOR_FONDO, highlightthickness=0
)
self._canvas_donut.pack(pady=(10, 2))
self._lbl_buscando = ttk.Label(
self.frame_cliente, text="Buscando...",
font=FUENTE_NOTA
)
self._lbl_buscando.pack()
# Contador de servidores
self._lbl_contador = ttk.Label(
self.frame_cliente, text="", font=FUENTE_NOTA
)
self._lbl_contador.pack(pady=(6, 0))
# Lista de servidores (se rellena en tiempo real)
self._frame_servidores = ttk.Frame(self.frame_cliente, style='TFrame')
self._frame_servidores.pack(pady=4, fill="x")
# Boton cancelar
self._btn_cancelar = ttk.Button(
self.frame_cliente, text="Cancelar búsqueda",
command=self.cancelar_busqueda
)
self._btn_cancelar.pack(pady=5)
self._donut_angle = 0
self._donut_animating = True
self._animar_donut()
hilo = threading.Thread(target=self._hilo_busqueda, daemon=True)
hilo.start()
self.root.after(2000, self._chequear_servidores_caidos)
# ------------------------------------------------------------------
# Animacion donut
# ------------------------------------------------------------------
def _animar_donut(self):
if not self._donut_animating:
return
try:
if not self._canvas_donut.winfo_exists():
return
except tk.TclError:
return
cx = cy = _DONUT_SIZE // 2
r = _DONUT_RADIUS
x0, y0 = cx - r, cy - r
x1, y1 = cx + r, cy + r
self._canvas_donut.delete("all")
# Track
self._canvas_donut.create_arc(
x0, y0, x1, y1,
start=0, extent=359.9,
style=tk.ARC, outline=_DONUT_TRACK, width=_DONUT_GROSOR
)
# Arco animado
self._canvas_donut.create_arc(
x0, y0, x1, y1,
start=self._donut_angle, extent=90,
style=tk.ARC, outline=_DONUT_COLOR, width=_DONUT_GROSOR
)
self._donut_angle = (self._donut_angle + _DONUT_STEP) % 360
self.root.after(_DONUT_MS, self._animar_donut)
def _detener_donut(self):
self._donut_animating = False
# ------------------------------------------------------------------
# Hilo de busqueda
# ------------------------------------------------------------------
def _hilo_busqueda(self):
descubrir_servidores(
timeout_sin_nuevos=10,
callback=lambda ip, puerto: self.root.after(
0, self._agregar_servidor, ip, puerto
),
on_seen=lambda ip, puerto: self.root.after(
0, self._actualizar_visto, ip, puerto
),
stop_event=self._stop_event,
)
if not self._busqueda_cancelada:
self.root.after(0, self._busqueda_finalizada)
# ------------------------------------------------------------------
# Callbacks de UI
# ------------------------------------------------------------------
_TIMEOUT_SERVIDOR = 8 # segundos sin broadcast → servidor caído
def _agregar_servidor(self, ip, puerto):
"""Añade un servidor a la lista en tiempo real.
Si llega 127.0.0.1 y ya hay una IP LAN con el mismo puerto, lo ignora.
Si llega una IP LAN y ya hay 127.0.0.1 con ese puerto, reemplaza la entrada.
"""
import time
LOOPBACK = "127.0.0.1"
# Buscar si ya existe una entrada con el mismo puerto
clave_existente = next(
(k for k in self.servidores_encontrados if k[1] == puerto),
None
)
if clave_existente is not None:
ip_existente = clave_existente[0]
if ip == LOOPBACK:
return # Ya hay IP LAN → ignorar loopback
if ip_existente == LOOPBACK:
# Reemplazar loopback por IP LAN
nueva_clave = (ip, puerto)
self.servidores_encontrados.remove(clave_existente)
self.servidores_encontrados.append(nueva_clave)
frame_srv = self._frames_servidores.pop(clave_existente)
self._frames_servidores[nueva_clave] = frame_srv
self._ultimo_visto[nueva_clave] = time.time()
del self._ultimo_visto[clave_existente]
for w in frame_srv.winfo_children():
if isinstance(w, ttk.Label):
w.config(text=ip)
break
return
return # Mismo puerto, IPs distintas no-loopback → ya existe
entrada = (ip, puerto)
self.servidores_encontrados.append(entrada)
self._ultimo_visto[entrada] = time.time()
frame_srv = ttk.Frame(self._frame_servidores, style='TFrame')
frame_srv.pack(pady=2, fill="x", padx=40)
self._frames_servidores[entrada] = frame_srv
ttk.Label(
frame_srv, text=ip, font=FUENTE_NORMAL
).pack(side="left", padx=5)
ttk.Button(
frame_srv, text="Conectar",
command=lambda e=entrada: self.pedir_clave_entrada(e),
style='Action.TButton'
).pack(side="left", padx=5)
self._actualizar_contador()
def _actualizar_visto(self, ip, puerto):
"""Actualiza el timestamp de último broadcast recibido para un servidor."""
import time
# Buscar por puerto (independientemente de si es loopback o LAN)
clave = next(
(k for k in self.servidores_encontrados if k[1] == puerto),
None
)
if clave:
self._ultimo_visto[clave] = time.time()
def _chequear_servidores_caidos(self):
"""Elimina de la lista los servidores que llevan más de _TIMEOUT_SERVIDOR sin broadcast."""
import time
if self._busqueda_cancelada:
return
try:
if not self.frame_cliente.winfo_exists():
return
except tk.TclError:
return
ahora = time.time()
caidos = [
k for k in list(self.servidores_encontrados)
if ahora - self._ultimo_visto.get(k, ahora) > self._TIMEOUT_SERVIDOR
]
for clave in caidos:
self.servidores_encontrados.remove(clave)
del self._ultimo_visto[clave]
frame = self._frames_servidores.pop(clave, None)
if frame and frame.winfo_exists():
frame.destroy()
if caidos:
self._actualizar_contador()
self.root.after(2000, self._chequear_servidores_caidos)
def _actualizar_contador(self):
if not self._lbl_contador or not self._lbl_contador.winfo_exists():
return
n = len(self.servidores_encontrados)
self._lbl_contador.config(
text=f"{n} servidor{'es' if n != 1 else ''} encontrado{'s' if n != 1 else ''}"
)
def _busqueda_finalizada(self):
self._detener_donut()
try:
if not self.frame_cliente.winfo_exists():
return
except tk.TclError:
return
# Ocultar donut y texto "Buscando..."
if self._canvas_donut and self._canvas_donut.winfo_exists():
self._canvas_donut.destroy()
if self._lbl_buscando and self._lbl_buscando.winfo_exists():
self._lbl_buscando.destroy()
if self._btn_cancelar and self._btn_cancelar.winfo_exists():
self._btn_cancelar.destroy()
if not self.servidores_encontrados:
if self._lbl_contador and self._lbl_contador.winfo_exists():
self._lbl_contador.config(
text="No se encontraron servidores.", foreground="red"
)
else:
if self._lbl_contador and self._lbl_contador.winfo_exists():
n = len(self.servidores_encontrados)
self._lbl_contador.config(
text=f"Búsqueda completada — {n} servidor{'es' if n != 1 else ''} encontrado{'s' if n != 1 else ''}",
foreground=COLOR_EXITO
)
frame_acciones = ttk.Frame(self.frame_cliente, style='TFrame')
frame_acciones.pack(pady=8)
ttk.Button(
frame_acciones, text="Actualizar",
command=self.iniciar_busqueda_cliente, style='Action.TButton'
).pack(side="left", padx=5)
ttk.Button(
frame_acciones, text="Volver",
command=self.volver_selector
).pack(side="left", padx=5)
# ------------------------------------------------------------------
# Cancelar / volver
# ------------------------------------------------------------------
def cancelar_busqueda(self):
self._busqueda_cancelada = True
if self._stop_event:
self._stop_event.set()
self._detener_donut()
for widget in self.frame_cliente.winfo_children():
widget.destroy()
self.frame_cliente.pack_forget()
def pedir_clave_entrada(self, srv_entrada):
ip, puerto = srv_entrada
dialogo = tk.Toplevel(self.root)
dialogo.title("Clave de acceso")
dialogo.geometry("350x160")
dialogo.resizable(False, False)
dialogo.transient(self.root)
dialogo.grab_set()
frame = ttk.Frame(dialogo, padding=15)
frame.pack(expand=True, fill="both")
ttk.Label(frame, text=f"Conectar a {ip}", font=FUENTE_NEGOCIOS).pack()
ttk.Label(frame, text="Clave (puerto#contraseña):", font=FUENTE_NORMAL).pack(pady=(5, 0))
entrada = ttk.Entry(frame, font=FUENTE_NORMAL)
entrada.pack(fill="x", pady=5)
entrada.focus_set()
def conectar(event=None):
contrasena = entrada.get().strip()
if contrasena:
dialogo.destroy()
self.conectar_a_servidor(ip, puerto, contrasena)
entrada.bind('<Return>', conectar)
ttk.Button(frame, text="Conectar", command=conectar, style='Action.TButton').pack()
def conectar_a_servidor(self, ip, puerto, contrasena):
# Detener busqueda antes de destruir widgets
self._busqueda_cancelada = True
if self._stop_event:
self._stop_event.set()
self._detener_donut()
print(f"[DEBUG SEL] Conectando a servidor: ip={ip} puerto={puerto} clave={contrasena!r}")
self.selector_frame.destroy()
self.panel_activo = ChatClientePanel(
self, self.root, ip, puerto, contrasena,
on_auth_error=self._auth_fallida
)
self.panel_activo.pack(expand=True, fill="both")
def _auth_fallida(self):
"""Destruye el panel de chat y vuelve al selector con un aviso de error."""
if self.panel_activo:
self.panel_activo.destroy()
self.panel_activo = None
self.crear_selector()
messagebox.showerror("Contraseña incorrecta", "La clave introducida no es válida.\nComprueba el formato: puerto#contraseña")
def volver_al_selector(self):
if self.panel_activo:
self.panel_activo = None
self.crear_selector()
def volver_selector(self):
for widget in self.frame_cliente.winfo_children():
widget.destroy()
self.frame_cliente.pack_forget()