1210 lines
44 KiB
Python
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() |