commit 34c5fc2b7b2f1c3df6c8d32ad1eaf7937700d947 Author: Levi Planelles Date: Tue Dec 2 17:08:52 2025 +0100 first commit diff --git a/__pycache__/proyecto.cpython-314.pyc b/__pycache__/proyecto.cpython-314.pyc new file mode 100644 index 0000000..bdc88df Binary files /dev/null and b/__pycache__/proyecto.cpython-314.pyc differ diff --git a/proyecto.py b/proyecto.py new file mode 100644 index 0000000..5ee5cd1 --- /dev/null +++ b/proyecto.py @@ -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