From a0ef53922e0d2f3d6cef9fa99f69215e43e7fb3a Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 21 Feb 2026 19:22:08 +0100 Subject: [PATCH] refactor: modularizar como PocketSync con soporte de perfiles Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 87 +++++ config.json | 105 ++--- core/__init__.py | 0 core/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 144 bytes core/__pycache__/config.cpython-310.pyc | Bin 0 -> 4720 bytes .../robocopy_engine.cpython-310.pyc | Bin 0 -> 2958 bytes core/__pycache__/sync_engine.cpython-310.pyc | Bin 0 -> 1153 bytes core/config.py | 135 +++++++ core/robocopy_engine.py | 109 ++++++ core/sync_engine.py | 27 ++ pocketsync.py | 26 ++ test_tkinter.py | 367 ------------------ ui/__init__.py | 0 ui/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 142 bytes ui/__pycache__/app.cpython-310.pyc | Bin 0 -> 6293 bytes ui/__pycache__/path_panel.cpython-310.pyc | Bin 0 -> 2830 bytes ui/__pycache__/profile_bar.cpython-310.pyc | Bin 0 -> 3820 bytes ui/__pycache__/status_bar.cpython-310.pyc | Bin 0 -> 2018 bytes ui/__pycache__/styles.cpython-310.pyc | Bin 0 -> 572 bytes ui/__pycache__/summary_panel.cpython-310.pyc | Bin 0 -> 1475 bytes ui/__pycache__/system_list.cpython-310.pyc | Bin 0 -> 2958 bytes ui/app.py | 220 +++++++++++ ui/path_panel.py | 66 ++++ ui/profile_bar.py | 93 +++++ ui/status_bar.py | 61 +++ ui/styles.py | 27 ++ ui/summary_panel.py | 33 ++ ui/system_list.py | 69 ++++ 28 files changed, 1010 insertions(+), 415 deletions(-) create mode 100644 CLAUDE.md create mode 100644 core/__init__.py create mode 100644 core/__pycache__/__init__.cpython-310.pyc create mode 100644 core/__pycache__/config.cpython-310.pyc create mode 100644 core/__pycache__/robocopy_engine.cpython-310.pyc create mode 100644 core/__pycache__/sync_engine.cpython-310.pyc create mode 100644 core/config.py create mode 100644 core/robocopy_engine.py create mode 100644 core/sync_engine.py create mode 100644 pocketsync.py delete mode 100644 test_tkinter.py create mode 100644 ui/__init__.py create mode 100644 ui/__pycache__/__init__.cpython-310.pyc create mode 100644 ui/__pycache__/app.cpython-310.pyc create mode 100644 ui/__pycache__/path_panel.cpython-310.pyc create mode 100644 ui/__pycache__/profile_bar.cpython-310.pyc create mode 100644 ui/__pycache__/status_bar.cpython-310.pyc create mode 100644 ui/__pycache__/styles.cpython-310.pyc create mode 100644 ui/__pycache__/summary_panel.cpython-310.pyc create mode 100644 ui/__pycache__/system_list.cpython-310.pyc create mode 100644 ui/app.py create mode 100644 ui/path_panel.py create mode 100644 ui/profile_bar.py create mode 100644 ui/status_bar.py create mode 100644 ui/styles.py create mode 100644 ui/summary_panel.py create mode 100644 ui/system_list.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..67f5ec6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,87 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Comandos + +**Ejecutar la aplicación:** +```bash +python pocketsync.py +``` + +No hay proceso de build, instalación de dependencias, ni tests automatizados. Todo depende de la librería estándar de Python 3 y del comando `robocopy` de Windows. + +## Arquitectura + +Aplicación de escritorio en Python/Tkinter para sincronizar archivos de emulación retro (ES-DE y ROMs) hacia almacenamiento externo usando Robocopy. Soporta múltiples perfiles nombrados (uno por consola/dispositivo). + +### Estructura de archivos + +``` +pocketsync.py # Entry point (~20 líneas) +config.json # Config v2 con perfiles +CLAUDE.md + +core/ + __init__.py + config.py # Profile dataclass + ConfigManager (load/save/migrate v1→v2) + sync_engine.py # ABC SyncEngine + robocopy_engine.py # RobocopySyncEngine (subprocess + parsing) + +ui/ + __init__.py + app.py # PocketSyncApp — orquestación y loop de sync + profile_bar.py # Widget: dropdown de perfiles + New/Rename/Delete + path_panel.py # Widget: los 2 selectores de ruta (ES-DE + ROMs) + system_list.py # Widget: listbox de sistemas + Select All/None + status_bar.py # Widget: barra de estado (sistema/fase/archivo) + summary_panel.py # Widget: área de texto de resumen + styles.py # Constantes visuales (colores, fuentes) +``` + +**Entry point:** `pocketsync.py` — instancia `ConfigManager` y `PocketSyncApp`. + +**Persistencia:** `config.json` (versión 2) guarda todos los perfiles. Si existe un config v1 (plano), se migra automáticamente a un perfil "Default" al primer arranque. + +### Esquema config.json (v2) + +```json +{ + "version": 2, + "active_profile": "Default", + "profiles": [ + { + "name": "Default", + "esde_src": "...", + "roms_src": "...", + "esde_dst": "...", + "roms_dst": "...", + "selected": ["arcade", "nes"] + } + ] +} +``` + +### Flujo de la aplicación + +1. Al iniciar: `ConfigManager.load()` restaura perfiles desde `config.json` (con migración automática de v1 → v2). +2. El usuario selecciona o crea un perfil en `ProfileBar`. +3. Configura 4 rutas (ES-DE origen/destino, ROMs origen/destino) y selecciona los sistemas. +4. Al pulsar "Sync Now": hilo daemon → `_sync_thread` en `app.py`. +5. Por cada sistema: 3 fases usando `SyncEngine.sync_folder()`. +6. Al cambiar de perfil: se guarda el estado actual en el perfil activo antes de cargar el nuevo. +7. Al cerrar: `ConfigManager.save()` persiste todos los perfiles. + +### Flags de Robocopy usados +- `/MIR` — modo espejo (sincroniza origen → destino) +- `/NP` — sin porcentaje de progreso en la salida + +### Threading +Las operaciones de copia corren en un hilo daemon separado para no bloquear la UI. Las actualizaciones de widgets se hacen desde ese hilo directamente. + +### Extensibilidad de backends +`core/sync_engine.py` define la ABC `SyncEngine`. `RobocopySyncEngine` es la implementación concreta. Para añadir rsync u otro backend: crear un nuevo archivo en `core/` que implemente la misma interfaz. + +## Plataforma + +Windows 10/11 obligatorio (depende de `robocopy`). Python 3.6+, sin dependencias externas. diff --git a/config.json b/config.json index 3230492..8c79707 100644 --- a/config.json +++ b/config.json @@ -1,51 +1,60 @@ { - "esde_src": "C:/Users/jaild/Retroid/ES-DE", - "roms_src": "C:/Users/jaild/Retroid/ROMs", - "esde_dst": "F:/ES-DE", - "roms_dst": "F:/ROMs", - "selected": [ - "arcade", - "arcadia", - "atari2600", - "atari5200", - "atari7800", - "atarijaguar", - "atarilynx", - "atomiswave", - "channelf", - "colecovision", - "dreamcast", - "fds", - "gamegear", - "gb", - "gba", - "gbc", - "intellivision", - "mastersystem", - "megadrive", - "megaduck", - "msx", - "msx2", - "n64", - "neogeo", - "neogeocd", - "nes", - "ngp", - "ngpc", - "pcengine", - "pcenginecd", - "psx", - "satellaview", - "saturn", - "sega32x", - "segacd", - "sg-1000", - "snes", - "supergrafx", - "supervision", - "videopac", - "virtualboy", - "wonderswan", - "wonderswancolor" + "version": 2, + "active_profile": "Default", + "profiles": [ + { + "name": "Default", + "esde_src": "C:/Users/jaild/Retroid/ES-DE", + "roms_src": "C:/Users/jaild/Retroid/ROMs", + "esde_dst": "F:/ES-DE", + "roms_dst": "F:/ROMs", + "selected": [ + "arcade", + "arcadia", + "atari2600", + "atari5200", + "atari7800", + "atarijaguar", + "atarilynx", + "atomiswave", + "channelf", + "colecovision", + "dreamcast", + "fds", + "gamegear", + "gb", + "gba", + "gbc", + "intellivision", + "mastersystem", + "megadrive", + "megaduck", + "msx", + "msx2", + "n64", + "nds", + "neogeo", + "neogeocd", + "nes", + "ngp", + "ngpc", + "pcengine", + "pcenginecd", + "psp", + "psx", + "satellaview", + "saturn", + "sega32x", + "segacd", + "sg-1000", + "snes", + "supergrafx", + "supervision", + "videopac", + "virtualboy", + "wonderswan", + "wonderswancolor" + ] + } ] } \ No newline at end of file diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/__pycache__/__init__.cpython-310.pyc b/core/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b0cc5e9bd5aa6435940206bd5b8d9c70358d39af GIT binary patch literal 144 zcmd1j<>g`k0@rUdGePuY5P=LBfgA@QE@lA|DGb33nv8xc8Hzx{2;!HqvsFxJacWU< zOjcrMPD+e>W=U#dOi5~SNqk9WUUq6xj9yG~eo<;ne0*kJW=VX!UP0w84x8Nkl+v73 NJCI?;OhAH#0RWT3An^bI literal 0 HcmV?d00001 diff --git a/core/__pycache__/config.cpython-310.pyc b/core/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..68a4b381902ee985d4f830257d1dbbcec7fd8f10 GIT binary patch literal 4720 zcma)ATW=f372cVhT`rdw$&{>E@g;?mxS`P~wR=lb*L7;yPJo0C>ZC~t8QTqKs8A-k z^z2HG2^laTzY0Yk3j~Gqs6WylF|U2{ALxUD!uifDNt2XZl*EkA%$^yZ>vzs7sZ>G- zpKt#2kIwx?$N4ucrauCU8yM*~n4}|F*J-kN;S7;Wn z7R^ApcO2ynGH$@p|ajP4rsqxw!)s@&2 zpLNpA1a}8nC+WxC=N!B(Ixzk5u(*MdKE}92@pd|0r)hzUYBSp z(^R3UN)sZ%IbufOciTsfUc&Olm75=Je3dGlZfwP!uH3lO$yB_NsWgkSPXB?@8*3Y_ zL@P}Fc4u>K@YocxB$Az0R>S#}E*4y{@z;MbTo~DCZNz7U_5QqaB&Jnv9YhAz4wz(; zA3`}$t`sB6n^69QDom(g(>dg_xKAbs8`c1(u%25ww?q-wzt$XeuF1&8-Bj?AULjtx zOFk{rqhcFQv}5biUP7*>{Q5tJs>-#Og%q zSar!HDXl^$;ANJ7S6CH#one;aSR(r1zCp+Sg=w6Kk|`I7!=;PF5elACKGuW_lr->? zLh7wL*duHZ%_33_o>igpu`5O?R~qg5*eS~j_{y>hz6Hx$Y5Q_fo}$zo@m0WAlS|lH zkQd}>xdLiYzM)P@A(!#&j9kS|ZEELDc@{fM;65kMgIbo$h?loa<))45&*Of)skABO zK^_g_?7J4>(fC6&+26*iO-7_)nNfCHT!6^%A*tRNmKt#bakKtwoo`kgF_>flJpAwH`j4u>laMqpsH<;e1Y>xEs$9R@WX62_o7tddH+p(%swu6`GPa-_LL8@a@W4W`1) zCQfx$zcnzYl=LA&E@1p+=nl`#NItiV&;ub1>q+ynea zF6}c&iV}AKc@G(LcHtxnJaQ2NkC58RYW#RGP<^Q>rsf@1ilrQj_y;_(xREvx`{t(($ey^nNcwG52D<~nv0}PLEzmCCb)&pu zlkv}EO+Z0vxsBUjLZ=UK(^N8v-2PW-_ZKvsyQ-hZ9a(@npkA`0G$BfEj+0zlmFSqV31WT@W}Dhos7oOz;N=jQ zcwG6}gR@9Mo~3}dY4cAISssGuu?|^^KJcD1d@G|~%O%i}?!NoC$`h7(2YzmmuhAk4 zeD_@D^n)u-dhrp0m%e|&_}b}*!pRDcua5WV#z%;ag8&7~JLEQkJXr1%Sh+a1=0SLf z)5MG2Qod_b)^8Z5UwGSD`~45m&IPL9N@S-(0neYie@qox$~I-5F%rFX8NU5s}WdKu!3cCMnB5tVYtFqCPs`i~Qk2~QEmRpD$FMew81yUl;$s0i=@%Qg zIRpOhGW3aX2l_;!#DwxDls};gwoeT9eZs;I==6gdo$o$~m#MR0_h)IXZS zsL`Ld$}K$H!myneXB?qA{`xP10EK)GJ*T{I%o-jpvH|;75YQj?APq^oG^8-GT^WLG z-PxnQgsg=(H=CLsQq%U?5eoc)4QvZqj$V89eB8^7LeA=XtiX~Kw!o6&w!jvU80gHgrtRQ4^7P(b=XmD&@Yd0RjgU@RJDyw>3Ldm3HH8Nipz)A(W?6wX2#H7NvZ);H9Q zS>O27{2Vxk)9nxX5_)^kuXD;XJhV<(c7~T`okF;9l0#r?3kudR@8Y~+kMu|MBfrD+ zoaDM)=S{!Um#UvN%c`GlYZb+5tJA3$O!em7#_dn)hXSi7X{Fz%iE<(@>sN?be48X){Y%>XkS0o6{S%sQ(L~{9 zWt@`RYG8$@Chk3qlw=8;i6Ja z(V!_Rjh{Uppi*cntrhe$dS)9dy5l4vOmHoKcfC#(+@?j~1W1ZSCOCH?&xIcWU~9eA literal 0 HcmV?d00001 diff --git a/core/__pycache__/robocopy_engine.cpython-310.pyc b/core/__pycache__/robocopy_engine.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b5049bc874006ec338592220d0ce041713ead7c0 GIT binary patch literal 2958 zcmaJDU2hc0vAgGUXUE3a2F%C75Ge{P;Ux)D5?P2KV?Zc}4`Bn!%BRtIyVs08JG1Vd z#opC8A~I3P14^VkC6|+I<%y?6%1fT|a{rjAyU7U>&@(=7B{7mTF08rNeal&ar8iSTLDD+vv+D5~m$TJeNZ8a=P z?htNr>p9_;u(pkc&FKQE*{?wYnXggp-i(qYT22Je-a;>J-bz>ER5Wq?;D?so08lMJ zf}p+zAkb8`vWlGUY2Jv;380Q+LT!u_oHU~*(Ttf4?oo2(8!Dnef)Nui1{qPopn=wOUg`wNK=WVg?mxmB!5LD zirkyIIkWUo38|J=qd4J9cj8<`OSw>an8)dwkW2NYW+uhbzI~x616}X*v>&T5dJ4-h z531;gNkEL6)CK_jrEmK{vp4+MRg&ySEinV3s=Bia7 zwgDW-M}xaBtu32Zwj2N#pi2L@ zY~=ngNsE@b>GET9z~6=$g@H4`im?~uDUrVbDI>dN%j=&iyq!^ge3x!jiz**KMe=Il zZPPU(H}hkKH{hLywOh_(vVIj3X)Hh9V!h}4$+UiTgYbz`>Yv|}I(dZDKfWi`JtXx{ zhoqh;rT+V^)alaJlSfGX{GFvcsJeVXL1(~O#x9^Gu4{{BC}@xhRTV8?3^enAZr9-f3|QwA^cq70{);F z0et>b2mPIzFnVE_8Nq&L)Gp3(3R0*Co3YA8a4BoY zdCap~f8tW}{n6SYmcw#pn5{`0r1i}!_wGLE8&~G<*G%aEM`EeaW-d1K#&|c4pTdcY zk}wq;2~FMc)wV35m@*4B~YxM>b{wjc)0`zlIkdGa#E*P9g;F@)Y$^MGY zmq!~;Jga|K)6G{@o2aGa%Hyz9SFWvyRBU$S^&g@*JWTAFNf@)=cyIdOJL0e^DDhz{ zOSq8rYf09O5_P@)R!Ys4ysXVTbGz31#4&FXS1R0O4_B#p9JY zuQ7QR<*SdGmoC9F7sLc{5PKrO%J~j@Kj@dmv4jzKos!!f`VQt2oxsv zDO*5}0h|*!au%LkpTMJQ$hU!i5yyvoXP-apyV$Q)=3{tNkUk4$V?xHDiqyZ~>KnmN zuuEzO?=fkPuYc~wjs4c=!dtunl zcsD`X55x6tlnh6lFyvVi*yeJUC2|}z!L1K*FDt+-c6~_fJZ`o{{v_k_N5GP3*#T?c z9auc4ctllD5||PYA;!L92K&-C+5dcteZg#RkIhd}iL+~tybR6YklA^!6Q?Vsay^DG koO+pWgV&A3N?R^EO1E~e4fc2)16)>5m$=lYKASZD4+YBUJOBUy literal 0 HcmV?d00001 diff --git a/core/__pycache__/sync_engine.cpython-310.pyc b/core/__pycache__/sync_engine.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6542274da145d3c4710f858a31cb23b3a51d472b GIT binary patch literal 1153 zcmZuwJ8u&~5Z>K;*tuLDMZ}}qGS^s0NE8U6fI$(Bn}CE)mR9TAb#h_v&e^@QWLHRu z_yLH9l9Gaoe?TOD#Fe3MTa|SP_CU=`FcQUBpqL08JJJ1#{pVCFI zoQ}BWG%LzNOGAaECe0IFG|ipQ#(c{wIfVfw~Cw%Qzd(K0G_F`jO$75Ygtx0 zPduo<5bI;mF6a+SvcSL>slI?4(77BektjgWi#Km6pmc&|!W?dQ#LJ^-e(B=%I|`&` zTog1Za++|#Dc8wSIxgr*1^r7p(nTT-U#&B5c2@xtO{r_^sPj*`qsnAXxuU}~ z=L+c1%X+h&fb7D~vbu7Tx6?&kEy)jT^ZDT(EJA=Y1TukE4%?|bm` zM2;(|#*#kQmZp?SHJdwycFsRgk(v?A9oCh?!{MVzO(`>AHz_NwI=$vy5!M?}?COnr z6T&)74dY{Yqh0q~a5C%InciIKcr_;q>NaCJ&m?1Yz*ts@N?AW->;&NEUs{ZbB4JEB z7R7--n6~Srw`}MRibCma8!suJdA28Qm(Z%AVA{yVgb=HLi9`N`F8LjLf+R{CP^vlU8%@1d&w%T=2b^F1r|A&@%V|QX3g?PvL2RITX;s5{u literal 0 HcmV?d00001 diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..2f8baed --- /dev/null +++ b/core/config.py @@ -0,0 +1,135 @@ +import json +import os +from dataclasses import dataclass, field +from typing import List, Optional + +CONFIG_VERSION = 2 + + +@dataclass +class Profile: + name: str + esde_src: str = "" + roms_src: str = "" + esde_dst: str = "" + roms_dst: str = "" + selected: List[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "name": self.name, + "esde_src": self.esde_src, + "roms_src": self.roms_src, + "esde_dst": self.esde_dst, + "roms_dst": self.roms_dst, + "selected": self.selected, + } + + @staticmethod + def from_dict(data: dict) -> "Profile": + return Profile( + name=data.get("name", "Default"), + esde_src=data.get("esde_src", ""), + roms_src=data.get("roms_src", ""), + esde_dst=data.get("esde_dst", ""), + roms_dst=data.get("roms_dst", ""), + selected=data.get("selected", []), + ) + + +class ConfigManager: + def __init__(self, config_path: str): + self.config_path = config_path + self.profiles: List[Profile] = [Profile(name="Default")] + self.active_profile_name: str = "Default" + + @property + def active_profile(self) -> Profile: + for p in self.profiles: + if p.name == self.active_profile_name: + return p + # Si el perfil activo no existe, devolver el primero + return self.profiles[0] + + def profile_names(self) -> List[str]: + return [p.name for p in self.profiles] + + def get_profile(self, name: str) -> Optional[Profile]: + for p in self.profiles: + if p.name == name: + return p + return None + + def add_profile(self, name: str) -> Profile: + p = Profile(name=name) + self.profiles.append(p) + return p + + def rename_profile(self, old_name: str, new_name: str) -> bool: + p = self.get_profile(old_name) + if p is None or self.get_profile(new_name) is not None: + return False + p.name = new_name + if self.active_profile_name == old_name: + self.active_profile_name = new_name + return True + + def delete_profile(self, name: str) -> bool: + if len(self.profiles) <= 1: + return False + p = self.get_profile(name) + if p is None: + return False + self.profiles.remove(p) + if self.active_profile_name == name: + self.active_profile_name = self.profiles[0].name + return True + + def load(self) -> None: + if not os.path.exists(self.config_path): + return + + try: + with open(self.config_path, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception: + return + + version = data.get("version", 1) + + if version == 1: + self._migrate_v1(data) + self.save() + else: + self._load_v2(data) + + def _migrate_v1(self, data: dict) -> None: + default = Profile( + name="Default", + esde_src=data.get("esde_src", ""), + roms_src=data.get("roms_src", ""), + esde_dst=data.get("esde_dst", ""), + roms_dst=data.get("roms_dst", ""), + selected=data.get("selected", []), + ) + self.profiles = [default] + self.active_profile_name = "Default" + + def _load_v2(self, data: dict) -> None: + raw_profiles = data.get("profiles", []) + if not raw_profiles: + return + self.profiles = [Profile.from_dict(p) for p in raw_profiles] + self.active_profile_name = data.get("active_profile", self.profiles[0].name) + # Asegurar que el perfil activo existe + if self.get_profile(self.active_profile_name) is None: + self.active_profile_name = self.profiles[0].name + + def save(self) -> None: + data = { + "version": CONFIG_VERSION, + "active_profile": self.active_profile_name, + "profiles": [p.to_dict() for p in self.profiles], + } + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4, ensure_ascii=False) diff --git a/core/robocopy_engine.py b/core/robocopy_engine.py new file mode 100644 index 0000000..b2546fe --- /dev/null +++ b/core/robocopy_engine.py @@ -0,0 +1,109 @@ +import os +import subprocess +from typing import Callable + +from core.sync_engine import SyncEngine + + +class RobocopySyncEngine(SyncEngine): + """Motor de sincronización basado en robocopy (Windows).""" + + def is_available(self) -> bool: + try: + result = subprocess.run( + ["robocopy", "/?"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + # robocopy devuelve 16 en error fatal, cualquier otro código es OK + return result.returncode != 16 + except FileNotFoundError: + return False + + def sync_folder( + self, + src: str, + dst: str, + on_file: Callable[[str], None], + on_summary: Callable[[str], None], + ) -> None: + if not os.path.isdir(src): + on_summary(" ⚠️ Carpeta no existe (omitido)") + on_file("(carpeta no existe)") + return + + os.makedirs(dst, exist_ok=True) + + cmd = ["robocopy", src, dst, "/MIR", "/NP"] + + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + universal_newlines=True, + ) + + files_copied = 0 + dirs_copied = 0 + total_bytes = 0 + + for line in process.stdout: + line = line.strip() + + if line and not line.startswith("---") and not line.startswith("Total"): + if len(line) > 5 and not any( + x in line + for x in ["Files :", "Dirs :", "Bytes :", "Speed :", "Times :"] + ): + on_file(line) + + if "Files :" in line: + parts = line.split() + try: + idx = parts.index("Files") + if idx + 2 < len(parts): + files_copied = int(parts[idx + 2]) + except (ValueError, IndexError): + pass + + elif "Dirs :" in line: + parts = line.split() + try: + idx = parts.index("Dirs") + if idx + 2 < len(parts): + dirs_copied = int(parts[idx + 2]) + except (ValueError, IndexError): + pass + + elif "Bytes :" in line: + parts = line.split() + try: + idx = parts.index("Bytes") + if idx + 2 < len(parts): + bytes_str = parts[idx + 2].replace(",", "").replace(".", "") + bytes_str = "".join(c for c in bytes_str if c.isdigit()) + if bytes_str: + total_bytes = int(bytes_str) + except (ValueError, IndexError): + pass + + process.wait() + + if files_copied > 0 or dirs_copied > 0: + size_str = self._format_bytes(total_bytes) + on_summary(f" ✓ {files_copied} archivos, {dirs_copied} carpetas ({size_str})") + else: + on_summary(" ✓ Sin cambios (ya sincronizado)") + + on_file("-") + + @staticmethod + def _format_bytes(n: int) -> str: + if n < 1024: + return f"{n} B" + if n < 1024 ** 2: + return f"{n / 1024:.2f} KB" + if n < 1024 ** 3: + return f"{n / 1024 ** 2:.2f} MB" + return f"{n / 1024 ** 3:.2f} GB" diff --git a/core/sync_engine.py b/core/sync_engine.py new file mode 100644 index 0000000..3dd8826 --- /dev/null +++ b/core/sync_engine.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod +from typing import Callable + + +class SyncEngine(ABC): + """Interfaz abstracta para motores de sincronización.""" + + @abstractmethod + def sync_folder( + self, + src: str, + dst: str, + on_file: Callable[[str], None], + on_summary: Callable[[str], None], + ) -> None: + """ + Sincroniza src → dst. + + on_file(path) — llamado con cada archivo que se procesa + on_summary(line) — llamado con cada línea de resumen al finalizar + """ + ... + + @abstractmethod + def is_available(self) -> bool: + """Devuelve True si el motor está disponible en el sistema actual.""" + ... diff --git a/pocketsync.py b/pocketsync.py new file mode 100644 index 0000000..3e99e9d --- /dev/null +++ b/pocketsync.py @@ -0,0 +1,26 @@ +import os +import sys +import tkinter as tk + +# Asegurar que el directorio raíz esté en el path para imports relativos +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +if BASE_DIR not in sys.path: + sys.path.insert(0, BASE_DIR) + +from core.config import ConfigManager +from ui.app import PocketSyncApp + +CONFIG_FILE = os.path.join(BASE_DIR, "config.json") + + +def main() -> None: + cm = ConfigManager(CONFIG_FILE) + cm.load() + + root = tk.Tk() + PocketSyncApp(root, cm) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/test_tkinter.py b/test_tkinter.py deleted file mode 100644 index e452a75..0000000 --- a/test_tkinter.py +++ /dev/null @@ -1,367 +0,0 @@ -import os -import json -import threading -import subprocess -import tkinter as tk -from tkinter import filedialog, messagebox - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -CONFIG_FILE = os.path.join(BASE_DIR, "config.json") - - -class DirectorySelectorApp: - def __init__(self, root): - self.root = root - self.root.title("Selector de Directorios ES-DE / ROMs") - self.root.geometry("700x700") - - # Variables de rutas - self.path_esde_src = tk.StringVar() - self.path_roms_src = tk.StringVar() - self.path_esde_dst = tk.StringVar() - self.path_roms_dst = tk.StringVar() - - # --------------------------- - # RUTAS DE ORIGEN - # --------------------------- - tk.Label(root, text="Rutas de origen", font=("Arial", 12, "bold")).pack(pady=(10, 0)) - - self.create_path_selector("Origen ES-DE:", self.path_esde_src) - self.create_path_selector("Origen ROMs:", self.path_roms_src, callback=self.update_directory_list) - - # --------------------------- - # LISTA DE DIRECTORIOS - # --------------------------- - tk.Label(root, text="Directorios encontrados en ROMs:", font=("Arial", 11)).pack(pady=(15, 5)) - - self.listbox = tk.Listbox(root, selectmode=tk.MULTIPLE, height=10) - self.listbox.pack(fill="both", expand=True, padx=10) - - # --------------------------- - # RUTAS DE DESTINO - # --------------------------- - tk.Label(root, text="Rutas de destino", font=("Arial", 12, "bold")).pack(pady=(15, 0)) - - self.create_path_selector("Destino ES-DE:", self.path_esde_dst) - self.create_path_selector("Destino ROMs:", self.path_roms_dst) - - # --------------------------- - # BOTÓN ROBOCOPY - # --------------------------- - tk.Button(root, text="Robocopy", font=("Arial", 12, "bold"), - command=self.run_robocopy).pack(pady=10) - - # --------------------------- - # LABELS DE ESTADO - # --------------------------- - status_frame = tk.Frame(root, bg="#2a2a2a", relief="sunken", bd=2) - status_frame.pack(fill="x", padx=10, pady=5) - - self.label_system = tk.Label(status_frame, text="Sistema: -", - font=("Arial", 10, "bold"), - bg="#2a2a2a", fg="#00ff00", anchor="w") - self.label_system.pack(fill="x", padx=10, pady=3) - - self.label_phase = tk.Label(status_frame, text="Fase: -", - font=("Arial", 10), - bg="#2a2a2a", fg="#00aaff", anchor="w") - self.label_phase.pack(fill="x", padx=10, pady=3) - - self.label_current = tk.Label(status_frame, text="Archivo: -", - font=("Arial", 9), - bg="#2a2a2a", fg="#ffaa00", anchor="w") - self.label_current.pack(fill="x", padx=10, pady=3) - - # --------------------------- - # RESUMEN - # --------------------------- - tk.Label(root, text="Resumen:", font=("Arial", 11)).pack(pady=(10, 5)) - - self.summary_text = tk.Text(root, height=6, state="disabled", bg="#f0f0f0", fg="#000") - self.summary_text.pack(fill="both", expand=False, padx=10, pady=5) - - # Evento de cierre - self.root.protocol("WM_DELETE_WINDOW", self.on_close) - - # Cargar configuración previa - self.load_config() - - # --------------------------------------------------------- - # CREAR SELECTOR DE RUTA - # --------------------------------------------------------- - def create_path_selector(self, label_text, var, callback=None): - frame = tk.Frame(self.root) - frame.pack(fill="x", padx=10, pady=5) - - tk.Label(frame, text=label_text, width=15, anchor="w").pack(side="left") - tk.Entry(frame, textvariable=var, width=50).pack(side="left", padx=5) - tk.Button(frame, text="Buscar", command=lambda: self.choose_path(var, callback)).pack(side="left") - - def choose_path(self, var, callback): - path = filedialog.askdirectory() - if not path: - return - var.set(path) - if callback: - callback(path) - - # --------------------------------------------------------- - # LISTA DE DIRECTORIOS - # --------------------------------------------------------- - def update_directory_list(self, path): - self.listbox.delete(0, tk.END) - - if not os.path.isdir(path): - return - - try: - dirs = sorted( - d for d in os.listdir(path) - if os.path.isdir(os.path.join(path, d)) - ) - for d in dirs: - self.listbox.insert(tk.END, d) - - except Exception as e: - messagebox.showerror("Error", f"No se pudo leer la ruta:\n{e}") - - # --------------------------------------------------------- - # ACTUALIZACIÓN DE LABELS Y RESUMEN - # --------------------------------------------------------- - def append_summary(self, text): - self.summary_text.configure(state="normal") - self.summary_text.insert(tk.END, text + "\n") - self.summary_text.see(tk.END) - self.summary_text.configure(state="disabled") - - def clear_summary(self): - self.summary_text.configure(state="normal") - self.summary_text.delete(1.0, tk.END) - self.summary_text.configure(state="disabled") - - def update_status_system(self, text): - self.label_system.config(text=text) - self.root.update_idletasks() - - def update_status_phase(self, text): - self.label_phase.config(text=text) - self.root.update_idletasks() - - def update_status_current(self, text): - # Limitar longitud para que no se salga de la ventana - if len(text) > 80: - text = "..." + text[-77:] - self.label_current.config(text=text) - self.root.update_idletasks() - - # --------------------------------------------------------- - # EJECUTAR ROBOCOPY (HILO) - # --------------------------------------------------------- - def run_robocopy(self): - thread = threading.Thread(target=self._run_robocopy_thread) - thread.daemon = True - thread.start() - - def _run_robocopy_thread(self): - selected = [self.listbox.get(i) for i in self.listbox.curselection()] - - if not selected: - self.append_summary("❌ No hay sistemas seleccionados.") - return - - esde_src = self.path_esde_src.get() - roms_src = self.path_roms_src.get() - esde_dst = self.path_esde_dst.get() - roms_dst = self.path_roms_dst.get() - - if not all([esde_src, roms_src, esde_dst, roms_dst]): - self.append_summary("❌ ERROR: Debes configurar todas las rutas antes de continuar.") - return - - self.clear_summary() - self.append_summary("=" * 60) - self.append_summary("🚀 INICIANDO PROCESO DE COPIA") - self.append_summary(f"📦 Total de sistemas a procesar: {len(selected)}") - self.append_summary("=" * 60) - - total_systems = len(selected) - - for idx, system in enumerate(selected, 1): - # Actualizar label de sistema - self.update_status_system(f"Sistema: {idx}/{total_systems} - {system.upper()}") - - self.append_summary(f"\n🎮 SISTEMA [{idx}/{total_systems}]: {system.upper()}") - - # ROMs - self.update_status_phase("Fase: [1/3] Copiando ROMs...") - self.append_summary(" 📁 [1/3] Copiando ROMs...") - self.launch_robocopy_with_log( - os.path.join(roms_src, system), - os.path.join(roms_dst, system) - ) - - # ES-DE gamelists - self.update_status_phase("Fase: [2/3] Copiando gamelists...") - self.append_summary(" 📋 [2/3] Copiando gamelists...") - self.launch_robocopy_with_log( - os.path.join(esde_src, "gamelists", system), - os.path.join(esde_dst, "gamelists", system) - ) - - # ES-DE downloaded_media - self.update_status_phase("Fase: [3/3] Copiando media...") - self.append_summary(" 🖼️ [3/3] Copiando media...") - self.launch_robocopy_with_log( - os.path.join(esde_src, "downloaded_media", system), - os.path.join(esde_dst, "downloaded_media", system) - ) - - self.append_summary(f" ✅ Sistema '{system}' completado\n") - - # Limpiar labels al finalizar - self.update_status_system("Sistema: ✅ COMPLETADO") - self.update_status_phase("Fase: -") - self.update_status_current("Archivo: -") - - self.append_summary("=" * 60) - self.append_summary("🎉 PROCESO COMPLETADO EXITOSAMENTE") - self.append_summary("=" * 60) - - def launch_robocopy_with_log(self, src, dst): - if not os.path.isdir(src): - self.append_summary(f" ⚠️ Carpeta no existe (omitido)") - self.update_status_current("Archivo: (carpeta no existe)") - return - - os.makedirs(dst, exist_ok=True) - - # Quitar /NFL y /NDL para ver los archivos en tiempo real - cmd = ["robocopy", src, dst, "/MIR", "/NP"] - - process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - universal_newlines=True - ) - - # Variables para extraer el resumen - files_copied = 0 - dirs_copied = 0 - total_bytes = 0 - - for line in process.stdout: - line = line.strip() - - # Actualizar label con el archivo actual - if line and not line.startswith("---") and not line.startswith("Total"): - # Si la línea parece un archivo siendo copiado - if len(line) > 5 and not any(x in line for x in ["Files :", "Dirs :", "Bytes :", "Speed :", "Times :"]): - self.update_status_current(f"Archivo: {line}") - - # Extraer información relevante del output de robocopy - if "Files :" in line: - parts = line.split() - try: - total_idx = parts.index("Files") - if total_idx + 2 < len(parts): - files_copied = int(parts[total_idx + 2]) - except (ValueError, IndexError): - pass - - elif "Dirs :" in line: - parts = line.split() - try: - total_idx = parts.index("Dirs") - if total_idx + 2 < len(parts): - dirs_copied = int(parts[total_idx + 2]) - except (ValueError, IndexError): - pass - - elif "Bytes :" in line: - parts = line.split() - try: - bytes_idx = parts.index("Bytes") - if bytes_idx + 2 < len(parts): - bytes_str = parts[bytes_idx + 2].replace(",", "").replace(".", "") - # Extraer solo números - bytes_str = ''.join(c for c in bytes_str if c.isdigit()) - if bytes_str: - total_bytes = int(bytes_str) - except (ValueError, IndexError): - pass - - process.wait() - - # Convertir bytes a formato legible - if files_copied > 0 or dirs_copied > 0: - if total_bytes < 1024: - size_str = f"{total_bytes} B" - elif total_bytes < 1024**2: - size_str = f"{total_bytes/1024:.2f} KB" - elif total_bytes < 1024**3: - size_str = f"{total_bytes/(1024**2):.2f} MB" - else: - size_str = f"{total_bytes/(1024**3):.2f} GB" - - self.append_summary(f" ✓ {files_copied} archivos, {dirs_copied} carpetas ({size_str})") - else: - self.append_summary(f" ✓ Sin cambios (ya sincronizado)") - - self.update_status_current("Archivo: -") - - # --------------------------------------------------------- - # PERSISTENCIA - # --------------------------------------------------------- - def save_config(self): - data = { - "esde_src": self.path_esde_src.get(), - "roms_src": self.path_roms_src.get(), - "esde_dst": self.path_esde_dst.get(), - "roms_dst": self.path_roms_dst.get(), - "selected": [self.listbox.get(i) for i in self.listbox.curselection()] - } - - try: - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4) - except Exception as e: - messagebox.showerror("Error", f"No se pudo guardar la configuración:\n{e}") - - def load_config(self): - if not os.path.exists(CONFIG_FILE): - return - - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - except Exception: - return - - self.path_esde_src.set(data.get("esde_src", "")) - self.path_roms_src.set(data.get("roms_src", "")) - self.path_esde_dst.set(data.get("esde_dst", "")) - self.path_roms_dst.set(data.get("roms_dst", "")) - - roms_path = self.path_roms_src.get() - if os.path.isdir(roms_path): - self.update_directory_list(roms_path) - - selected = set(data.get("selected", [])) - for i in range(self.listbox.size()): - if self.listbox.get(i) in selected: - self.listbox.selection_set(i) - - # --------------------------------------------------------- - # CIERRE - # --------------------------------------------------------- - def on_close(self): - self.save_config() - self.root.destroy() - - -if __name__ == "__main__": - root = tk.Tk() - app = DirectorySelectorApp(root) - root.mainloop() \ No newline at end of file diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/__pycache__/__init__.cpython-310.pyc b/ui/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d45cefc81f946a8598ac39e94a56215a552991fa GIT binary patch literal 142 zcmd1j<>g`k0{3q-GePuY5P=LBfgA@QE@lA|DGb33nv8xc8Hzx{2;!HavsFxJacWU< zOjcrMPD+e>W=U#dOi5~SNqk9WUUq6xj9yG>W=wp1W?p7Ve7s&kI^f`tJ9O0^&+ literal 0 HcmV?d00001 diff --git a/ui/__pycache__/app.cpython-310.pyc b/ui/__pycache__/app.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2336dfcf88b2269a1ae07ba3114a34d49ce8528f GIT binary patch literal 6293 zcmbtYNpl;=6~-(W3x|Im{GTg%{JAc~-?tNzL#wa2D7iX6D!h;4BdyW-YeN zE@HNF3hxrTjG1}%+Jx2>b`>*KeEAx?4rl?8%WeQ#1ay=A2+##)LH<8ZN}FNpF^@@_ ztA~e)DZ(&L$}Oo3udB4tB6Ax52&T4%7OkKgYQv0|8S3b@Sbv(qe`A;#nI{|*snsO!?e*IM^ZMScCQ_K-aPh3bfqlK(rnGvtJ+{`N-`^B)Wi}z?u%Zdr2&8ZBbdxYO63cT zz;$M%F^%|^upYx)zQvWp|LnJ!e;Bk9TeOCjW5zR*pbgmWV(StXaQ@B^2--!&r{Of5}+mE!(f5!umaIe zX%FxeDm&ITWrlh3wlT8fv*6xJ^o354H2%pd)V*YiEqlCRU$+bc_pB{TbCR5XdOSCEv?t6FYpVW8i z-h=hVos9>zo65dlr&W8Ohg}|vBSCwTqCx@Yot=A0DIMLbuYY`RM-cBx)dPp={Yk}= z6vpE{UyvkPkCW`?>K*U1q!jVkYxzO2hsh|JqsJw>CVnZ1KvdRdtL+$h&&N+BI*9+f#|9yxk!b8#D-X6gqQ$( z@cC8{M*O)ZE&xe-71b@%u!`;~TJ%1;qIOxU8jEllru?)1YiBFQl2+D=m?g+GOifp- zKbTqTKSg^9YfIXqK3INfzHq!N6W4dft&oe9ZsiI9c^PnNJNn2tp^@G($CpCP0il)^ zZ{c5(OZ6Zs7&S{{#gm~&$-=Zd^=MYy1>!8bzrkDsEux!rMH}3lIrKToo;p8mqbARP z4*;FNqaDBGK=Vw=O%~i}9=XGuf&&RM#yM{P;((fhH9Z10Y3=w4v6~ClSiP$V^9BxhA7F zFta0C%gMNb=rnQ^ruZ4w3Zvq9Uysy>@g(XMMkTD3X&=S@n%+n z$xHbH@d<`f)Z#utWPXVrM&z&JgCyIISX(HG+lS#{Kk#Fo6lwQZ;$jn9Qn8C|qCu&_ zsY|?oVG}K)yHUn-SvQkJzQz8Ajg3+&)@F9v^87c^+-x&MTE+>|+`c9px^Cb>SNR;)U<=Yph`$V7Uzy`&RE~#HPV%Qi-$vu^PR&*>s6f{v zHBHHJv*5Eyh9}P9Ts*{SW?B9f=DKK7cNMr+8!Qv!HsHb4v#g(vVn!IS#K6o{MoSKM z;$$hTk0mhFPxNDBXb9w7DgVZHMrqAZT$QI|q9BdcOcgr504zLoFyF_seEb^oPtc_5 z%90P9{~Xm#e_%>=bfG7gTAAKhb;tTp7fAL-Fh6))nQUTYN+m)y<5(f1LwP!g&ln(b z9T^Fw0saY}oMq)*%pIf21iA=TQe~XO%*=;A!>%cgrrZ>rZLEs13sL9R<^30QhsXDv zNX;JXJLs1W9-}0RO3wWnuym0ofx+m2hy4L3?Yo^2w1oaN~qGm84VAInl9PfB5o7@(2PR|j zF-7Z~oXf^Giauj#4zv5_Q!RQ2D?~>Il$4G>%BOCFR#GdJSgA9Va5hFjd8(l(8V92Z zwpuy1;=)j4)>HjX=HX~pKh7Ofp;R1|SZA@ zLw7AaY@>b)-K~xL(aOro;G+AAm73nnApp}`R?`8H)*OQyDY;)xk=sXhK?RPac@;EY zz1+-CkOx;&V(&~5>vGoi>2Q}o==>!-B!y3Ul;LpZN&G>kp#6D40!x@+q3_e0Sph_cS}$4fa!X8wp- zmuF&@d5_a$mJwIV(QZldoz z7Q~9UNF7z&GObc+vOz=2kP`zZ6qEten@ku8$_xdiRe}-{LBU^8$P}b9L5Du2OPNS7 z(;U@Vlxaj%UfY>P%_%x>J4Q8MEmf^0r)pITPR_}e>k_keQCS&jKgq?9QMSSG=-ACd zD-?W1IX-oMI%{)LPK)#n+fz@S^4+spkxNpe>2#LJ>ixEQfe zDk@(xDDzJU@zm>veD0{woO<9$_frax(Ed{|De`Saa(13@68n9ns1M1!qRZ%PS!a-; JPybbo{{aWK6rlhB literal 0 HcmV?d00001 diff --git a/ui/__pycache__/path_panel.cpython-310.pyc b/ui/__pycache__/path_panel.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..77e16f0fe0335497d4b4b08cc34b6ea61a27fc69 GIT binary patch literal 2830 zcmZ`*&2JPp6t_K|+1X_ilJNaex;?aF4y2V@sTCkZAOYHjfJj@>RjTQ*_J-Na&IH>F zBpRejP$if2Ua5yf>Lvd{{}x|+%DGYxP>H_h$-)9+R(`hM^T+%6z2_~)#u@~^U;q6* z{W&D$Z=8&N9442c>1#j)5i}!h3O&n6pS2l9o|k!j-sXM3?YlY8f_|-CqvSdfz6hQX z5lGq#pL*@Opo=7`?ZH`OA)>a?O*1LdBrBF9X2YvVmL>NxX~SEqCN1(LgEfIRYnjv? z+%x)tw(iq^K_{J7Z6;{j6HM|h+aO&kJi(te+rBsf8gNmQL9Zr48Or(z(hWpiG@kkG z##?+Y4v6M6-aY^;>yj^1;dj{yBF3QSVq6>qSyPPR9b!UE!fb4RHYE4mKvC( zb+%CHvdmiraI{JDBAT|oNewLY_Wgk!My&?z05p9Uh#^l&kCwDcOJ-QlgT|rxg8sov z?@#(0rNji=wH;EDID9p#mt?ykSV?-}4#e8cJebvSCJ5-y7b0%Ck&lh#-699Nnuy1a zu00Rt7SGMjoqz7%xph;Yj}BO04_2kJVH~GiKXkQk;#$)=f>dP6{(h{mj>yQyLJ_W zG`+f}4#I)XwWnbaP)6BH#yEAq7c2;#H|h;K&Zg-4)T`i54{ctsK7d?i)_VvBg&n~B zy`;0!!I%v{bPH;YG1b2SF=U%=(jEGQm%Ilk7fg6t5CcPcUdc-;xbO{!cm`WO$N}YB z7*Wy-O1{ZN9b`4p*uuRv#P|N*umq%{Y=dmj4c7G>zC$swztWxJ;yk%v>Ek^Hszj{K zA#F2-9@xX`VdBadgYYao%iPKDh25?GOEn+O?jK#KXU zJ^`j%yoX+hn2L~rXSahZ1Kml~JL!)L4?)vSAe(fXY_d(V!xqUd#mZ8PL4g7S`vHh| zJ@pZE>Nt`X66C5gKx}w1OZxXja_M83X*94&)}xWIjY4bsVpc%F^d%TnyCTmSURhe} z6#agZi>R(pHS1qfNnc_zbXKfCk5R7>-*AYZpSdzOZ~Zxd=$frqi#KQH=PlT5Op!-^ zrSXSwR;4ytVf`+iTAA=EiqH;q49F>H8jS?>(KGA}AQG?%be!`4xZilOe(XJly;D69 zc~|kcX&{Kc9rDC0Jslz%O1jNTZwC-oGIgYc@lzP11e-bsTfi^q%XTx-D zJI=!Z>KEA|v*f4X2`d@eM9BjVTqY<~H4m#A^vqQ}%qnlo1G_lXIe-1a9l4xpqmtGz zWm^vh5{h&y%^w0PT1f_y{=GC&tt_d^HI!{EebWj-s>Xms)Io6}^6mJeH18IGQ>Z&o zr)&*Nx+>OODAf_%aTIrWmA{;;0?4mrY2I>{odmzeSPJ8zZlA$UjVamm5DZ7@`l;7W zduMfr!ph-?V{{|B?H|lRaESxJFtiYJ;93t9*`<|oE^e=aXG0LVD*Y9nfrmBTsD(rE zd+${p2{Xz^f95>_m!63=@CAByfMF-d(V9-5qu7s(>cstfw zg`TRQiK(eBBbh~l{|wbPNalgSV_ZG29hqhD!oNlaR;Q{~1u#NvYCJt8oKCSb6UWC+ zhvVV6H|3r56t0LGwq{n++(=dFg7LC}SzCoa1C=_cm}P@>zpAuRn$VC3!*XkM&5 Ta0y|q5#Awb(h1sRN4@_658jD~ literal 0 HcmV?d00001 diff --git a/ui/__pycache__/profile_bar.cpython-310.pyc b/ui/__pycache__/profile_bar.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0d33ef1bb3dec1671e686809025b62f200bc0c44 GIT binary patch literal 3820 zcmZ`+OK%&=5uWaOazyGu$+qOx?lQlIQJk}fCBcSL;gT60XDxN*S_ZDbAUbMUDY$BNXt9I6sx=XQScpI9m7M?8n^1&I^PK?J{P-%R$JVDC5P3?i3hI%07x&{zz0oZ<}xqO;c1rFN|)K z_4{GUUQjeU#Syig;*28PVEUkr??ZV1ftzCBoYK~vQ{$8<3y)S@gl;YV`MsOF`@fQ0 z$o*azCv5+2obzx$=Q0oSI6dTIe`kLf?+*%61)Wgr433p|H3|D27GB%NP0%IF7=7i8 zc{@Q6r*R$xJ6Dq|3KMw^lwwANqo)t*Q(pyQAugam_W&w0OPDfKest!q*h2ed5U->1 z8i+6a_?H$GmvF}H%gd1wg@Jc+QF@caPk_Q=MDxer@r=$fzaRuz+TPVH_qG^fm zp#DP?@1Xb*ip6tQHOcb;h?sQXnB-e9LgEoRjzfGh+MN4}g@MY(aFNOhqdb1h7t)^% z^Ido`CF(NwvC)HA+cKV-V{<|nW#$PcEON{E-O5vIZ1(8b8e3g5DU z8q*UIRv*ZwQ;sru z%Fcxc`n!%kW77H9m_Twnfeayj3{9w%ZUAKA5f)97o{|Dwjpi)$zKgT*xO`*u)*N(# zcfprO=VKmX*@k)yaZ`s`t)?x$2EWQF8L<>H7L@r-d=)JwoC1u}2m2G~5Z6$=i((Un z_6NzLqgc7qouNm6mQSn1Biq2%QT@Dw=OCF;419W z=X4$Hh@8aGs$!_l15uvw0ubmr#)Nj~H=yKD%o*?)%B%U@dD5O(5o~ zA|GW(9M=}Vz|O@(72kk9gis=3SBV9Go|c&PHtFcn9INK@^#Y=}5oQUEXd9F%wVslk z_Dqc}Vq<;+8ID|Agqz@yWes34o&(IEox@JkD@gY-7qpG-VuGW0SNl9 zK(0uuQ{>?Oxr<&l*YH|{^(+YBeh~CCHcU{j2EhkVYD$Y61T2f-GJuH#5^*jBUIvua z$+ART1yvxY#ZOV-e|G}oLO^Qm;Gh0?)sKrtuG0j(Ogn)ei#FeLVO9=(wx*NI{EPcE??Sl q?W3Z@xVWj=cLZL0ae=u=*AAgCaGx#ExJIC*D~JbCze=j~qWOP<>v@OooAX^4`7P~{?!Ftp~QAW(!*-8KnGj$6s81eVdT@r>KGH)}I9 zrio=vN!3%iB1Dl=iA(+y{)RciDd$QYa_V~<=c9#+vG(noncu#7AHR7X6pJB&@ykEI z#^2=#`5PzahYOSI@aQTCK?F@mi^7{FY|CjmlnAHgt`Xr1Z;uF1(w?{Lw0uFEq~agK zH>6Ra*4Jhyk^1ls%saSve&7)}^!FfSX1>J)Z8?ufmkK7Fy`bd^S9-z|e%BQ_5$w5e z#Mw$-gs_r3w=yFNuo8$_Q4}T6@{fp1S|KWdbzA)ZRA#TrQD6QEbo*f)wv)4I4DOQGm4UGhoR@w;lMk9X9gbqCkR7! zNsmrwmm1b{L>9~wXTtuVKT}GK+w&%nIpf1_&IG$mT|!NGOC)948hi!syzoWtu|FX^ zoRD7Fn-PJ4kj2bn$p6$z;T*njiZ24b_@DS9;7jnJ{n=Ce9N_0(#0vsRD>a~mqqHy6 zLkzg$Shji3`bs9T?6%mZ7>CW{L@k%ELcQJ1K_V=DQ1Ps5Cur+-_A~9cfP7MR}fX0qK5au!>QrsdaYi!r73jv>w1QqnIa)Li9u?a zbge&(luV8F`wt>@Paja5=e(0dT62Ctj;`HYZr{~X>2@!Q6VYCajf~m`SjtTdbx`eU zdla`JEMPL#4E%3?5{KfNB+)AXWM zEVmh{wDAJxG4WWvoA5k)69h0QeC4WvX%bi8op`WfVv?y9S3 z7W*1_a~bsyQGA5rV-znPn@5{jxij1+02#mb;)&VrzYawIFhBYh2ty`xmm1o4)dc~) z!9Z8I2xSxGA$y%Hk)J5!47-Ihp}&#enQ*6hWc%((9xGmB>{P2&HHSXrPp^jh0OoM# zoJ6KT%2t^WxyzyOh@7@EdTR-lV?0W*7!6epk#1;EKb{e%YkEtpKm zKKxlc%_y)BAx<^u=BQ;&#chn10D0Ot{(ADTeDn7Do%+f~?bdCz0DDLDD%9p0XzEiG zNbCwdX2wg^@t|uAYNS#Z0b%BKYDsf-kWUS?K~&U@lv9o&&|~ B-D>~< literal 0 HcmV?d00001 diff --git a/ui/__pycache__/styles.cpython-310.pyc b/ui/__pycache__/styles.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..331522b317a02bb1ba9f50af0adf9b35013261c5 GIT binary patch literal 572 zcmYjM&2F1O5a!P|U}O6cEcN6=Q`BQsRmCv|Bz~d*ay&vpTkUQ|OGD))hvZHA2z{HJ zbIr*w&_hlc2#qqMnfbo?+u6plObLbj`%}GFBW~YB9+$5J3VFJ8@9@W z(-a>9?ORjRQ+L|QK(|&vKX0x@QxkFQPP&`C`$#%PVB4JAb`J>Q90BKq^cik<+LvsU zYK61aJ^u4F!X@!~`7qrjaaQh}Cxu6=+aUJ9>{;k++;G5YlpXIoyqgu`FyIL2S~?&-t%ry>{ZD2gAXs%ha>Eh9zCL|eAi z^-p#z&Wf9=5bbTv#M$rL2mSM}hThcdi;O$O3Z15yiZ1K2DQmK(qWzB@R(1Iqi=>JK literal 0 HcmV?d00001 diff --git a/ui/__pycache__/summary_panel.cpython-310.pyc b/ui/__pycache__/summary_panel.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1ea47074503f1b3b9c51fbf1d52252edaa913810 GIT binary patch literal 1475 zcmZuxOK%%D5GJ|Y)#_olaNM*gf>tQ{+7@-&OAkT%u#?yqTMps`XaoTSB~sE#)Ji2O z)G~C7ft=iXfgT#57yl*Rik^JysfV08vvL!=Ef+H+haYF>o0;9|+FFgk_~oyk^WP&v z{=m)E!C~_qi2DSTB8nQ)rZ6+ZCQ&=0WRIvw@l&F>rX&6|YR8Hmku1IdJZWV#B+f64 zc0Jr*9q1W{y8}v>UfWF3cJz$&sbVTRP1_v2#wu1xpR0;WPkB30byZb0SXG`8PTDEF zs}UMU^T|Zo#RFMrbMkdLHI;69ee9>Oa6{S6P41DeMt`D|cs7cbkQ&cNv0`V@D8ZDRtxhU% z0tDbJ1*wA6ROKvL5>a20Q5~?ULTdwLP1Vjgg){@Hdg~;8xC-rd#sWtwf%m6{&)7u* zkz8P$kY7HNYGKh5Qk6T|HCknHNQOEe41I{5mtF_f9R$`N*cfrVJN3iejDx@2n2_k> znJiT4>DVIOY#r{+XWE8Th`h+X5P|t|i1%ez8;frawcW$kBeB1|bAKOi_Qruf!We5u zj}H#EA3hN~d#~2Jd!bt54-Q+0Ar>W@_`&u)@qH+#Cs`D@)21I1D3C6ENX9=(J86{SfYCS9i+ z^yJ1h@+(x20^*;6Yy4CrV*Sj)*@}M zyxP@~RL>UVBE=a{&IP=X% z7xj8X;P>S>e@TBA67m%e7JnQDZ$Z=dKnNmeMtbF!_85#=Mu%R{qd4bfeCYRl*XLO< ztn@0Byi0^Hf+HdV$p+zLuU8dxgS0A7VL#GsQ5y}V*2#|CD)wQ-JE=DC4YWDPr0(OY z#UJ{EKRo~;NskHwe$bxxIoYOy3GXQEalwUuM0#+psK7T6p{$6ih_<;{615}V3q@5{ zW#t93%|u-+!%QTulryt+4e=bzE`3fo>D9pB6&r0FXd{Q{-{eh{;BZsOriQK@Cc4=# z^5#}yid^dEhRkH&H1A|t^VRvF3z~MHIrmF}mkFRPLDT;LVaQ`Lpi{a{Q!-_Lr(aS^ z3>$b;2)y9~U$A3u5C~83<5E+ZqCONpXsaSP=2H?!Q!xRtD7L&_q+Rreg^)JbmFdo| zQ58^G4&mK>(DGo5G|Oxt_eV)CobkS;`>N{f)iQik0|jn4i7t}S@)Vw0?pEm+!=ba! zwj=(E&i?b@2X@7*L{0z06}&sE@KcU zaL?L&kfS#o{FK?Y4MvHQxv|0CqeSiKUUkdl@iy-AgxYEx_p?OnIDSI@`Nli9Hb2r* z>CHisW@7W*)W~GhNNr-1=6h0Ywl~M=rgr#_(XsaEKwXD>)u8ELfw)FFW0X-&`M)?1 zzPk}zqY+!Vn5F!?zz@FVmDwscj=r5-y{MY@JbHZ!JI;UD#}K{)?dxkG0DT5*XH9EPz z=+s4^|F2)_W!&Wz+-2s#l?w;@nM{;(LA7z&t0oc>Ah1(djM2)02cH%-NHB9O%q5Wz`^x;65cKBk9k z%GSuI5s(1}yXq*$1^eOmKL$?UfQ*>v;2tH1oRUeyES=Pl#i#tQyy_%lvyV8q$#uJ4CIa)GvER}-V&(-W-L?+Ltsz5aogp#dnx>wDXjUQ z<9#uSe}o%<+Bl>J2=atE>3u<_^pu@a#isN(gp!BwA^{cAE))qXw0a%vte%>U$oB25 z=qH(8ZJ!%ke_g3UO1`6d3{#W~n$h1F}REN}WIp5U$< z&hLP5)!zfrU^OUjMr$;r;aTXd(BN#DHEH-6JG)fIbIVKl!s8_j$VY%X zEa+2$*n{kN|D9+A_f=>b4R>+^6-Jr$F;CVX|7+d`k)?1n`)SSCq zbQ^&O^gdc$#ceT4H^}7Wg%pDQj8&yrNaMNP{GeKYtj=qF;T%`x?y%b8R=++!j$SJB z#WmK?fu!}N@NJf0^HG4R$^c=UcEq;~N~(23>(xqfklzz7&te^{qNStQZq`Sfej9+0o8=n&T`>E ZceDdbc)PsaD%Oi5nGnKM!#{T2`w!}Z%BTPU literal 0 HcmV?d00001 diff --git a/ui/app.py b/ui/app.py new file mode 100644 index 0000000..27db7d2 --- /dev/null +++ b/ui/app.py @@ -0,0 +1,220 @@ +import os +import threading +import tkinter as tk + +from core.config import ConfigManager, Profile +from core.robocopy_engine import RobocopySyncEngine +from core.sync_engine import SyncEngine +from ui import styles +from ui.path_panel import PathPanel +from ui.profile_bar import ProfileBar +from ui.status_bar import StatusBar +from ui.summary_panel import SummaryPanel +from ui.system_list import SystemList + + +class PocketSyncApp: + def __init__(self, root: tk.Tk, config_manager: ConfigManager): + self.root = root + self.cm = config_manager + self.engine: SyncEngine = RobocopySyncEngine() + + self._build_ui() + self._load_profile(self.cm.active_profile) + + # ------------------------------------------------------------------ + # Construcción de la UI + # ------------------------------------------------------------------ + + def _build_ui(self) -> None: + self.root.title("PocketSync") + self.root.geometry(f"{styles.WINDOW_WIDTH}x{styles.WINDOW_HEIGHT}") + + # --- Barra de perfiles --- + self.profile_bar = ProfileBar(self.root, on_change=self._on_profile_change) + self.profile_bar.pack(fill="x", padx=styles.PAD_X, pady=(8, 2)) + self.profile_bar.set_callbacks( + on_new=self._on_new_profile, + on_rename=self._on_rename_profile, + on_delete=self._on_delete_profile, + ) + self._refresh_profile_bar() + + # --- Rutas de origen --- + self.src_panel = PathPanel(self.root, title="Rutas de origen") + self.src_panel.pack(fill="x", padx=styles.PAD_X, pady=styles.PAD_Y) + self.src_panel.set_roms_callback(self._on_roms_src_change) + + # --- Lista de sistemas --- + self.system_list = SystemList(self.root) + self.system_list.pack(fill="both", expand=True, padx=styles.PAD_X) + + # --- Rutas de destino --- + self.dst_panel = PathPanel(self.root, title="Rutas de destino") + self.dst_panel.pack(fill="x", padx=styles.PAD_X, pady=styles.PAD_Y) + + # --- Botón Sync --- + tk.Button( + self.root, + text="Sync Now", + font=styles.FONT_BUTTON, + command=self._run_sync, + ).pack(pady=6) + + # --- Barra de estado --- + self.status_bar = StatusBar(self.root) + self.status_bar.pack(fill="x", padx=styles.PAD_X, pady=styles.PAD_Y) + + # --- Panel de resumen --- + self.summary = SummaryPanel(self.root) + self.summary.pack(fill="x", padx=styles.PAD_X, pady=styles.PAD_Y) + + self.root.protocol("WM_DELETE_WINDOW", self._on_close) + + # ------------------------------------------------------------------ + # Gestión de perfiles + # ------------------------------------------------------------------ + + def _refresh_profile_bar(self) -> None: + self.profile_bar.refresh(self.cm.profile_names(), self.cm.active_profile_name) + + def _collect_ui_into_profile(self) -> None: + """Escribe el estado actual de la UI en el perfil activo.""" + p = self.cm.active_profile + p.esde_src = self.src_panel.get_esde() + p.roms_src = self.src_panel.get_roms() + p.esde_dst = self.dst_panel.get_esde() + p.roms_dst = self.dst_panel.get_roms() + p.selected = self.system_list.get_selected() + + def _load_profile(self, profile: Profile) -> None: + self.src_panel.set_esde(profile.esde_src) + self.src_panel.set_roms(profile.roms_src) + self.dst_panel.set_esde(profile.esde_dst) + self.dst_panel.set_roms(profile.roms_dst) + + if os.path.isdir(profile.roms_src): + self.system_list.populate(profile.roms_src) + else: + self.system_list.populate("") + + self.system_list.set_selected(profile.selected) + + def _on_profile_change(self, name: str) -> None: + self._collect_ui_into_profile() + self.cm.active_profile_name = name + self._load_profile(self.cm.active_profile) + self._refresh_profile_bar() + + def _on_new_profile(self, name: str) -> bool: + if self.cm.get_profile(name) is not None: + return False + self._collect_ui_into_profile() + self.cm.add_profile(name) + self.cm.active_profile_name = name + self._load_profile(self.cm.active_profile) + self._refresh_profile_bar() + return True + + def _on_rename_profile(self, old: str, new: str) -> bool: + ok = self.cm.rename_profile(old, new) + if ok: + self._refresh_profile_bar() + return ok + + def _on_delete_profile(self, name: str) -> bool: + ok = self.cm.delete_profile(name) + if ok: + self._load_profile(self.cm.active_profile) + self._refresh_profile_bar() + return ok + + # ------------------------------------------------------------------ + # Callbacks de widgets + # ------------------------------------------------------------------ + + def _on_roms_src_change(self, path: str) -> None: + self.system_list.populate(path) + + # ------------------------------------------------------------------ + # Loop de sincronización + # ------------------------------------------------------------------ + + def _run_sync(self) -> None: + thread = threading.Thread(target=self._sync_thread) + thread.daemon = True + thread.start() + + def _sync_thread(self) -> None: + selected = self.system_list.get_selected() + + if not selected: + self.summary.append("No hay sistemas seleccionados.") + return + + esde_src = self.src_panel.get_esde() + roms_src = self.src_panel.get_roms() + esde_dst = self.dst_panel.get_esde() + roms_dst = self.dst_panel.get_roms() + + if not all([esde_src, roms_src, esde_dst, roms_dst]): + self.summary.append("ERROR: Debes configurar todas las rutas antes de continuar.") + return + + self.summary.clear() + self.summary.append("=" * 60) + self.summary.append("INICIANDO PROCESO DE COPIA") + self.summary.append(f"Total de sistemas a procesar: {len(selected)}") + self.summary.append("=" * 60) + + total = len(selected) + + for idx, system in enumerate(selected, 1): + self.status_bar.set_system(f"Sistema: {idx}/{total} - {system.upper()}") + self.summary.append(f"\nSISTEMA [{idx}/{total}]: {system.upper()}") + + # Fase 1: ROMs + self.status_bar.set_phase("Fase: [1/3] Copiando ROMs...") + self.summary.append(" [1/3] Copiando ROMs...") + self.engine.sync_folder( + os.path.join(roms_src, system), + os.path.join(roms_dst, system), + on_file=self.status_bar.set_file, + on_summary=self.summary.append, + ) + + # Fase 2: gamelists + self.status_bar.set_phase("Fase: [2/3] Copiando gamelists...") + self.summary.append(" [2/3] Copiando gamelists...") + self.engine.sync_folder( + os.path.join(esde_src, "gamelists", system), + os.path.join(esde_dst, "gamelists", system), + on_file=self.status_bar.set_file, + on_summary=self.summary.append, + ) + + # Fase 3: media + self.status_bar.set_phase("Fase: [3/3] Copiando media...") + self.summary.append(" [3/3] Copiando media...") + self.engine.sync_folder( + os.path.join(esde_src, "downloaded_media", system), + os.path.join(esde_dst, "downloaded_media", system), + on_file=self.status_bar.set_file, + on_summary=self.summary.append, + ) + + self.summary.append(f" Sistema '{system}' completado\n") + + self.status_bar.reset() + self.summary.append("=" * 60) + self.summary.append("PROCESO COMPLETADO EXITOSAMENTE") + self.summary.append("=" * 60) + + # ------------------------------------------------------------------ + # Cierre + # ------------------------------------------------------------------ + + def _on_close(self) -> None: + self._collect_ui_into_profile() + self.cm.save() + self.root.destroy() diff --git a/ui/path_panel.py b/ui/path_panel.py new file mode 100644 index 0000000..ca16c67 --- /dev/null +++ b/ui/path_panel.py @@ -0,0 +1,66 @@ +import tkinter as tk +from tkinter import filedialog +from typing import Callable, Optional + +from ui import styles + + +class PathPanel(tk.LabelFrame): + """Panel con dos selectores de ruta (origen o destino).""" + + def __init__(self, parent, title: str, **kwargs): + super().__init__( + parent, + text=title, + font=styles.FONT_HEADING, + padx=styles.PAD_X, + pady=styles.PAD_Y, + **kwargs, + ) + + self.path_esde = tk.StringVar() + self.path_roms = tk.StringVar() + + self._add_selector("ES-DE:", self.path_esde) + self._add_selector("ROMs:", self.path_roms) + + def _add_selector(self, label: str, var: tk.StringVar, callback: Optional[Callable] = None): + frame = tk.Frame(self) + frame.pack(fill="x", pady=2) + + tk.Label(frame, text=label, width=10, anchor="w", font=styles.FONT_LABEL).pack(side="left") + tk.Entry(frame, textvariable=var, width=55, font=styles.FONT_SMALL).pack(side="left", padx=4) + tk.Button( + frame, + text="Buscar", + font=styles.FONT_SMALL, + command=lambda: self._choose(var, callback), + ).pack(side="left") + + def _choose(self, var: tk.StringVar, callback: Optional[Callable]): + path = filedialog.askdirectory() + if not path: + return + var.set(path) + if callback: + callback(path) + + def set_roms_callback(self, callback: Callable[[str], None]) -> None: + """Registra callback que se invoca al cambiar la ruta de ROMs.""" + # Reemplaza el selector de ROMs con uno que tenga el callback + for widget in self.winfo_children(): + widget.destroy() + self._add_selector("ES-DE:", self.path_esde) + self._add_selector("ROMs:", self.path_roms, callback=callback) + + def get_esde(self) -> str: + return self.path_esde.get() + + def get_roms(self) -> str: + return self.path_roms.get() + + def set_esde(self, value: str) -> None: + self.path_esde.set(value) + + def set_roms(self, value: str) -> None: + self.path_roms.set(value) diff --git a/ui/profile_bar.py b/ui/profile_bar.py new file mode 100644 index 0000000..96a33e2 --- /dev/null +++ b/ui/profile_bar.py @@ -0,0 +1,93 @@ +import tkinter as tk +from tkinter import simpledialog, messagebox +from typing import Callable, List + +from ui import styles + + +class ProfileBar(tk.Frame): + """Barra superior con dropdown de perfiles y botones New/Rename/Delete.""" + + def __init__(self, parent, on_change: Callable[[str], None], **kwargs): + super().__init__(parent, **kwargs) + + self._on_change = on_change + + tk.Label(self, text="Perfil:", font=styles.FONT_LABEL).pack(side="left", padx=(0, 4)) + + self._var = tk.StringVar() + self._dropdown = tk.OptionMenu(self, self._var, "") + self._dropdown.config(font=styles.FONT_LABEL, width=20) + self._dropdown.pack(side="left", padx=4) + + tk.Button(self, text="New", font=styles.FONT_SMALL, command=self._new_profile).pack(side="left", padx=2) + tk.Button(self, text="Rename", font=styles.FONT_SMALL, command=self._rename_profile).pack(side="left", padx=2) + tk.Button(self, text="Delete", font=styles.FONT_SMALL, command=self._delete_profile).pack(side="left", padx=2) + + # Callbacks inyectados desde app.py + self._cb_new: Callable[[str], bool] = lambda name: False + self._cb_rename: Callable[[str, str], bool] = lambda old, new: False + self._cb_delete: Callable[[str], bool] = lambda name: False + + # ------------------------------------------------------------------ + # API pública + # ------------------------------------------------------------------ + + def set_callbacks( + self, + on_new: Callable[[str], bool], + on_rename: Callable[[str, str], bool], + on_delete: Callable[[str], bool], + ) -> None: + self._cb_new = on_new + self._cb_rename = on_rename + self._cb_delete = on_delete + + def refresh(self, names: List[str], active: str) -> None: + """Actualiza el dropdown con la lista de nombres de perfil.""" + menu = self._dropdown["menu"] + menu.delete(0, "end") + for name in names: + menu.add_command(label=name, command=lambda n=name: self._select(n)) + self._var.set(active) + + def current(self) -> str: + return self._var.get() + + # ------------------------------------------------------------------ + # Acciones internas + # ------------------------------------------------------------------ + + def _select(self, name: str) -> None: + self._var.set(name) + self._on_change(name) + + def _new_profile(self) -> None: + name = simpledialog.askstring("Nuevo perfil", "Nombre del perfil:", parent=self) + if not name: + return + name = name.strip() + if not name: + return + if self._cb_new(name): + self._on_change(name) + else: + messagebox.showerror("Error", f"Ya existe un perfil con el nombre '{name}'.") + + def _rename_profile(self) -> None: + old = self._var.get() + new = simpledialog.askstring("Renombrar perfil", f"Nuevo nombre para '{old}':", parent=self) + if not new: + return + new = new.strip() + if not new: + return + if not self._cb_rename(old, new): + messagebox.showerror("Error", f"No se pudo renombrar el perfil.") + + def _delete_profile(self) -> None: + name = self._var.get() + if not messagebox.askyesno("Eliminar perfil", f"¿Eliminar el perfil '{name}'?"): + return + if not self._cb_delete(name): + messagebox.showerror("Error", "No se puede eliminar el único perfil existente.") diff --git a/ui/status_bar.py b/ui/status_bar.py new file mode 100644 index 0000000..334ef41 --- /dev/null +++ b/ui/status_bar.py @@ -0,0 +1,61 @@ +import tkinter as tk + +from ui import styles + +_MAX_FILE_LEN = 80 + + +class StatusBar(tk.Frame): + """Barra de estado con tres labels: sistema, fase y archivo actual.""" + + def __init__(self, parent, **kwargs): + super().__init__(parent, bg=styles.STATUS_BG, relief="sunken", bd=2, **kwargs) + + self._label_system = tk.Label( + self, + text="Sistema: -", + font=styles.FONT_LABEL + ("bold",) if isinstance(styles.FONT_LABEL, tuple) else styles.FONT_LABEL, + bg=styles.STATUS_BG, + fg=styles.STATUS_SYSTEM_FG, + anchor="w", + ) + self._label_system.pack(fill="x", padx=10, pady=3) + + self._label_phase = tk.Label( + self, + text="Fase: -", + font=styles.FONT_LABEL, + bg=styles.STATUS_BG, + fg=styles.STATUS_PHASE_FG, + anchor="w", + ) + self._label_phase.pack(fill="x", padx=10, pady=3) + + self._label_file = tk.Label( + self, + text="Archivo: -", + font=styles.FONT_SMALL, + bg=styles.STATUS_BG, + fg=styles.STATUS_FILE_FG, + anchor="w", + ) + self._label_file.pack(fill="x", padx=10, pady=3) + + def set_system(self, text: str) -> None: + self._label_system.config(text=text) + self._label_system.update_idletasks() + + def set_phase(self, text: str) -> None: + self._label_phase.config(text=text) + self._label_phase.update_idletasks() + + def set_file(self, text: str) -> None: + if len(text) > _MAX_FILE_LEN: + text = "..." + text[-(_MAX_FILE_LEN - 3):] + self._label_file.config(text=f"Archivo: {text}") + self._label_file.update_idletasks() + + def reset(self) -> None: + self.set_system("Sistema: ✅ COMPLETADO") + self.set_phase("Fase: -") + self.set_file("-") diff --git a/ui/styles.py b/ui/styles.py new file mode 100644 index 0000000..2c42629 --- /dev/null +++ b/ui/styles.py @@ -0,0 +1,27 @@ +# Constantes visuales de PocketSync + +FONT_FAMILY = "Segoe UI" + +FONT_HEADING = (FONT_FAMILY, 11, "bold") +FONT_LABEL = (FONT_FAMILY, 10) +FONT_SMALL = (FONT_FAMILY, 9) +FONT_BUTTON = (FONT_FAMILY, 11, "bold") +FONT_MONO = ("Consolas", 9) + +# Colores de la barra de estado +STATUS_BG = "#2a2a2a" +STATUS_SYSTEM_FG = "#00ff00" +STATUS_PHASE_FG = "#00aaff" +STATUS_FILE_FG = "#ffaa00" + +# Colores del panel de resumen +SUMMARY_BG = "#f0f0f0" +SUMMARY_FG = "#000000" + +# Dimensiones de ventana +WINDOW_WIDTH = 800 +WINDOW_HEIGHT = 720 + +# Padding genérico +PAD_X = 10 +PAD_Y = 5 diff --git a/ui/summary_panel.py b/ui/summary_panel.py new file mode 100644 index 0000000..6a6aa1c --- /dev/null +++ b/ui/summary_panel.py @@ -0,0 +1,33 @@ +import tkinter as tk + +from ui import styles + + +class SummaryPanel(tk.Frame): + """Panel de texto deshabilitado para mostrar el resumen de sync.""" + + def __init__(self, parent, **kwargs): + super().__init__(parent, **kwargs) + + tk.Label(self, text="Resumen:", font=styles.FONT_LABEL).pack(pady=(6, 2)) + + self._text = tk.Text( + self, + height=6, + state="disabled", + bg=styles.SUMMARY_BG, + fg=styles.SUMMARY_FG, + font=styles.FONT_MONO, + ) + self._text.pack(fill="both", expand=False, padx=styles.PAD_X, pady=styles.PAD_Y) + + def append(self, line: str) -> None: + self._text.configure(state="normal") + self._text.insert(tk.END, line + "\n") + self._text.see(tk.END) + self._text.configure(state="disabled") + + def clear(self) -> None: + self._text.configure(state="normal") + self._text.delete(1.0, tk.END) + self._text.configure(state="disabled") diff --git a/ui/system_list.py b/ui/system_list.py new file mode 100644 index 0000000..2c5de17 --- /dev/null +++ b/ui/system_list.py @@ -0,0 +1,69 @@ +import os +import tkinter as tk +from tkinter import messagebox +from typing import List + +from ui import styles + + +class SystemList(tk.Frame): + """Listbox de sistemas con botones Select All / Select None.""" + + def __init__(self, parent, **kwargs): + super().__init__(parent, **kwargs) + + tk.Label( + self, + text="Sistemas encontrados en ROMs:", + font=styles.FONT_LABEL, + ).pack(pady=(6, 2)) + + self.listbox = tk.Listbox(self, selectmode=tk.MULTIPLE, height=10, font=styles.FONT_SMALL) + self.listbox.pack(fill="both", expand=True, padx=styles.PAD_X) + + btn_frame = tk.Frame(self) + btn_frame.pack(fill="x", padx=styles.PAD_X, pady=2) + + tk.Button( + btn_frame, + text="Select All", + font=styles.FONT_SMALL, + command=self._select_all, + ).pack(side="left", padx=2) + + tk.Button( + btn_frame, + text="Select None", + font=styles.FONT_SMALL, + command=self._select_none, + ).pack(side="left", padx=2) + + def _select_all(self): + self.listbox.selection_set(0, tk.END) + + def _select_none(self): + self.listbox.selection_clear(0, tk.END) + + def populate(self, path: str) -> None: + """Rellena el listbox con los subdirectorios de path.""" + self.listbox.delete(0, tk.END) + if not os.path.isdir(path): + return + try: + dirs = sorted( + d for d in os.listdir(path) if os.path.isdir(os.path.join(path, d)) + ) + for d in dirs: + self.listbox.insert(tk.END, d) + except Exception as e: + messagebox.showerror("Error", f"No se pudo leer la ruta:\n{e}") + + def get_selected(self) -> List[str]: + return [self.listbox.get(i) for i in self.listbox.curselection()] + + def set_selected(self, names: List[str]) -> None: + selected = set(names) + self.listbox.selection_clear(0, tk.END) + for i in range(self.listbox.size()): + if self.listbox.get(i) in selected: + self.listbox.selection_set(i)