proyecto/proyecto.py

1210 lines
44 KiB
Python

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("<Configure>", 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()